一、为什么依赖锁值得单独写一篇
PHP 项目里最容易被忽略的文件,大概就是 composer.lock。它躺在仓库根目录,几十万字节,Git 里每次发版都有一大坨 diff,review 的人基本都是划过去。可真出问题的时候,它又是第一个被怀疑的对象:本地能跑、服务器报 Class not found;昨天还好好的,今天部署完白屏;同事新拉一份代码,PHP 版本一样、扩展一样,就是起不来。
这些现象背后往往是同一件事:你以为你锁定了依赖,其实没有。composer.lock 只是记录了「上一次 install 时解出来的那棵树」,它能保证的事情比大多数人想的要窄。本文按「锁什么、谁在破坏锁、怎么验证锁、出事怎么回」四步讲,全是可以在自己项目上立刻执行的操作。
二、锁定的是解析结果,不是运行环境
Composer 有两类命令,语义完全不同,混着用是大多数事故的起点:
composer install:读 composer.lock,按里面记的版本和dist.reference(通常是 git commit)下载,不会升级任何包。锁文件不存在时才退化成 update 并生成锁。composer update:按 composer.json 的版本约束重新解析整棵树,写出新的锁文件。它只认 json 里的约束,不关心你上一次锁了什么。
所以「我在服务器上跑 composer update 修了个问题」这句话,在线上环境基本等于「我把依赖树换成了今天的样子」。它可能修好了眼前这个报错,也可能悄悄把另外三个包的次版本抬高,而这三个包的行为变化没人 review 过。
更关键的是锁文件覆盖不到的部分:
- PHP 版本与扩展。锁文件里有
platform段和每个包的require.php,但没有记录你本机装没装bcmath、intl、redis。这些属于require.ext-*约束,install 时会检查,可一旦有人用--ignore-platform-reqs绕过,问题就推到运行时才炸。 - autoload 的生成结果。composer 的 classmap 是在 install 时生成的,包更新了但没重跑
dump-autoload,就会出现「文件是新的、类映射是旧的」这种极难查的错。 - composer 自身的版本。Composer 1 和 2 解析出的树可能不同,锁文件的
plugin-api-version、content-hash校验逻辑也不同。2026 年了还在用 Composer 1 的机器,跑 install 会直接拒绝。
结论一句话:composer.lock 保证的是「依赖版本一致」,不保证「运行结果一致」。后面所有操作都是围绕这个差异补漏。
三、五条最常用的命令与它们的真实语义
把下面这几条记熟,能省掉大量猜测:
# 1) 严格按锁安装,生产环境唯一正确的用法
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
# 2) 只检查锁文件是否与 json 同步,不做任何下载(CI 用)
composer validate --strict
composer install --dry-run --no-dev
# 3) 看某个包为什么被装上(依赖链溯源)
composer why monolog/monolog
composer why-not monolog/monolog 2.9.0
# 4) 输出已装包清单(含版本与描述),便于比对新旧机器
composer show --direct
composer show --format=json > /tmp/pkgs.json
# 5) 重新生成 autoload,不改依赖
composer dump-autoload --optimize --classmap-authoritative几条容易被忽略的细节:
composer install发现锁文件与 json 不一致时,会警告「lock file is not up to date」并继续按旧锁安装,然后你的新依赖就是不生效,但也不报错。这种现象在「我明明加了包却没加载」的排查里非常常见。--classmap-authoritative会让 autoload 只信 classmap,不再走 PSR-4 的文件系统查找。生产环境开启能省不少 I/O,但代价是动态生成或者运行期才出现的类会找不到。有插件机制、有class_exists探测的项目要谨慎。--no-dev只影响本次 install,不会把 dev 包标记到锁文件里。锁文件始终包含 dev 依赖,所以require-dev里的包同样会被拉到生产机上的 vendor 目录之外——它们不会装,但版本约束仍然参与解析。composer validate --strict会在「约束写得太宽」(如*、dev-master)时返回非 0 退出码。把它放进 CI,能挡住一批随手的宽约束。
四、谁在悄悄破坏锁文件
锁文件被改,通常不是有人故意,而是下面这些路径:
第一,合并冲突时手工解决锁文件。 两个分支各自 composer update 了不同的包,合并时 composer.lock 冲突。很多人直接选一边、「反正能跑」。这时 json 里可能已经有 A 分支的新包,锁文件却是 B 分支的树,install 会警告不匹配然后按旧树装。正确做法是把锁文件回退到目标分支版本,然后按 json 重新 composer update 具体包名 生成一次。
第二,CI 里跑了 update。 为了「保证最新」,流水线里写 composer update 的部署脚本不少。这等于让线上依赖随上游漂移,同一天两次部署可能装到不同代码。一旦上游发了破坏性补丁,回滚代码也回滚不了依赖,因为锁文件本身被改写了。
第三,本地 PHP 版本比生产高。 本地 PHP 8.3、生产 8.1 时,composer update 可能选到要求 php >=8.2 的包版本。锁文件记下这个版本,生产机 install 时才会报平台约束不满足。要在 composer.json 里显式写 "platform": {"php": "8.1.27"},让本地解析就按生产环境来。
第四,忘了把 lock 提交。 有些项目把 composer.lock 加进 .gitignore(以前类库项目的惯例)。应用项目这么做等于放弃锁定,每次部署的依赖树都是当天的解析结果。
第五,私有仓库或镜像不可达。 锁文件里记录的是 dist.url,如果用的是私有 Satis 或国内镜像且镜像已失效,install 会去尝试 source 方式 clone,速度和成功率都不可控。锁文件里的 dist 字段还带着 reference,换了镜像源后最好整体重锁一次,别让新旧源混在一份锁里。
五、验证锁文件是否可信:四个检查点
不做任何投机的排查,只做这四件事,基本能定位九成的「本地能跑线上不行」:
检查点一,content-hash 是否匹配。 锁文件顶部的 content-hash 是 composer.json 相关字段的哈希。执行 composer validate --strict,如果提示「lock file is not up to date with the latest changes in composer.json」,就说明有人在改 json 后没有重锁。这是最便宜的一次判断。
检查点二,platform 段是否反映真实服务器。 打开锁文件找 "platform" 和 "platform-overrides"。如果这里是空的,说明解析时用的是本机环境。把它和服务器上 php -v、php -m 的输出对比,重点看 intl、bcmath、gd、sodium 这几个经常缺的扩展。
检查点三,用 composer check-platform-reqs 对齐。 这条命令会逐个列出所有已装包对 PHP 版本和扩展的要求是否满足,输出里出现 missing 就是硬伤。建议在部署脚本里作为一道闸门:
composer install --no-dev --prefer-dist --no-interaction || exit 1
composer check-platform-reqs --no-dev || { echo "平台约束不满足,终止部署"; exit 1; }
composer dump-autoload --optimize --no-dev
php -l vendor/composer/autoload_real.php检查点四,比对两次全新安装的结果。 在干净目录里 composer install 一次,然后 composer show --format=json 输出到文件;在服务器上做同样的事,两份 JSON 做 diff。如果包版本完全一致但行为不同,问题一定在扩展、配置或者 autoload,不在依赖版本本身。这个对比能把「依赖问题」和「环境问题」一次性切开,比反复猜要快得多。
六、依赖锁出问题时怎么回滚
依赖出事的第一个念头通常是「改回去」,但回滚的对象要选对:
- 代码回滚了、依赖没回滚:如果部署流程是「git pull 完再 composer install」,那么回滚到上一个 commit 时锁文件也一起回去了,install 会把 vendor 换成旧树——但旧包的文件可能残留在 vendor 里(composer 会清理,前提是用 install 而不是手工拷贝)。
- 依赖回滚了、数据库结构没回滚:包里带了迁移脚本的(如某些 ORM 或队列库),降版本后表结构可能已经超前。回滚前先确认这次升级有没有跑过迁移。
- 最稳的回滚方式:把锁文件与 vendor 目录一起做版本化。做法是在部署时保留上一版
releases/目录(软链切换那种),回滚就是把软链指回上一版,vendor 原样不动,几秒钟完成,不依赖网络、不依赖 Packagist。
如果线上已经坏了、又必须原地修,最安全的一步是「先锁死当前版本」而不是「升级到最新」:
# 只升级指定的这一个包,其余全部保持锁文件里的版本
composer update vendor/package --with-dependencies
# 反过来:把一个已经升上去的包降回指定版本
composer require vendor/package:1.4.2 --no-update
composer update vendor/package --with-dependencies注意 --with-dependencies 会连带动它的依赖,动完一定要看 git diff composer.lock,确认只有预期的包变了。锁文件的 diff 是可读的,每个包的 version 和 reference 都在,认真看一眼比任何猜测都值。
七、把规则固化进流程
零散地与依赖问题搏斗没必要,几条规则就能把风险降到很低:
- 生产环境只允许 install。把
composer update从所有部署脚本里删掉,需要升级时在开发分支上升级、提交锁文件、走一次完整发布。 - composer.json 里锁定
platform.php,与生产一致。这样本地 update 出来的树在生产上一定装得上。 - CI 第一步跑
composer validate --strict,不通过直接失败。它能挡住锁文件没提交、json 与锁不同步两类问题。 - 升级依赖单独成一次发布。不要「顺手修个 bug 顺便 update 两个包」,那样出问题时无法区分是代码还是依赖。
- 保留至少一个可回滚的 releases 目录,vendor 一起版本化。这是唯一在网络不可用、Packagist 挂掉时还救得回来的方案。
八、小结
composer.lock 的价值不在于它记录了版本号,而在于它把「解析」和「安装」分成了两步,让你能在一个确定的时间点上冻结整棵树。所有破坏锁文件的行为,本质都是把这两步又合并回了一次性动作。记住三件事就够用:生产只 install 不 update、platform 必须与服务器一致、升级依赖单独发布。剩下的排查手段——validate --strict、check-platform-reqs、show --format=json 对比——都是围绕这三条规则的验证工具,成本极低,值得写进部署脚本里。