循环依赖是架构问题而非版本问题,需抽离公共契约建独立包解决。执行composer depends --tree定位闭环,删require-dev中测试工具排查隐式依赖,严禁用replace/provide伪装兼容。

composer update --dry-run -v 看到反复回溯同一组包,就是循环依赖
Composer 不会“尝试解开”循环依赖,它一发现 A → B → A 这类路径就立刻中止,并在 verbose 输出末尾反复展示相同的回溯链。这不是卡顿或网络问题,是明确的架构信号:两个包不该互相 require 对方。
执行 composer update --dry-run -v 后,重点盯输出最后 10 行——如果出现类似这样的重复路径:
package-a v1.2.0 requires package-b ^2.0 package-b v2.3.0 requires package-a ^1.0 package-a v1.2.0 requires package-b ^2.0
那就实锤了。此时别急着删 vendor/ 或 composer.lock,它们是诊断依据,不是病灶。
composer depends --tree vendor/a 定位闭环源头
这个命令是唯一能直接看到依赖方向的工具,但有两个硬前提:项目必须已成功运行过 composer install(否则依赖图不完整),且必须带 --tree 参数(否则只显示一级)。
假设你怀疑 myorg/core 被循环引入,运行:
composer depends myorg/core --tree
若输出中出现:
myorg/core ← myorg/api ← myorg/core
箭头表示“被谁依赖”,自身出现两次即闭环。常见误判点:
- 输出为空?说明该包根本没进
composer.lock,可能已被早期拦截,先删vendor/和composer.lock再重试composer update --dry-run - 只看到
myorg/core ← myorg/api?那是正常单向依赖,--tree没加全,命令无效
抽离公共契约是唯一可持续的破环方式
所谓“绕过”“忽略”“强制安装”全是临时补丁,真正解决问题得从结构上切断闭环。核心动作是把双方共用的东西拎出来,建一个新包,比如 myorg/contracts。
这个新包的 composer.json 必须干净:
-
"require": {}—— 不能有任何外部依赖 -
"autoload": {"psr-4": {"MyOrg\Contracts\": "src/"}}—— 命名空间独立 -
myorg/core和myorg/api都只require "myorg/contracts": "^1.0",彼此之间不再require
注意:别用 replace 或 provide 去伪装兼容——这会让 autoload 错乱,运行时反而报 Class not found。
require-dev 是最常被忽略的隐式循环来源
很多“循环依赖”其实不是业务包之间的问题,而是测试工具反向污染了主依赖链。典型现象:删掉 phpunit/phpunit,composer update 突然成功。
原因往往是:
-
phpunit依赖了老版sebastian/exporter,而你的主业务包需要新版symfony/console,后者又要求sebastian/exporter >=4.0 -
autoload配置越界:比如"psr-4": {"App\": "src/"},但src/里偷偷用了phpunit的断言类,Composer 就把它当生产依赖来解析
排查建议:
- 临时注释掉
require-dev下所有非核心工具(phpunit、phpstan、mockery),再跑composer update --dry-run - 检查
autoload-dev是否只覆盖tests/或stubs/,且命名空间不与autoload重叠
循环依赖的本质不是版本选错了,而是两个模块本不该知道彼此的存在。一旦依赖图里出现闭环,无论怎么调版本号都只是拖延爆炸时间。











