Composer依赖锁实战:install与update的边界

一、为什么依赖锁值得单独写一篇

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 对比——都是围绕这三条规则的验证工具,成本极低,值得写进部署脚本里。

Last modification:October 31st, 2026 at 12:25 pm

Leave a Comment