循环依赖需通过报错信息含嵌套指向自身、--dry-run反复跳转、卡在Resolving dependencies超30秒、depends --tree显示闭环路径来确认,而非普通版本冲突。

怎么确认是循环依赖,不是普通版本冲突
报错里出现 Package a depends on b, which depends on a 这类嵌套指向自身的描述,基本就是实锤。但更常见的是伪装成“无法安装 root package”或卡在 Resolving dependencies 超过 30 秒——SAT 求解器正在死循环里打转,不是慢,是绕不出去。
执行 composer update --dry-run -v,看最后几行是否反复回溯同一组包(比如 vendor/a → vendor/b → vendor/a);或者直接运行 composer depends myorg/core --tree,输出中若出现 myorg/core ← myorg/api ← myorg/core,箭头方向是“被谁依赖”,自身出现两次即闭环。
注意两个硬性前提:composer depends --tree 必须在已成功执行过 composer install 的项目里运行,否则依赖图不完整;且必须加 --tree 参数,否则只显示一级依赖,看不到闭环。
为什么 composer update --lock 不能解决循环依赖
composer update --lock 只刷新 lock 文件结构、重排依赖顺序、校验哈希一致性,它不修改任何包的版本,也不重算依赖拓扑——而循环依赖是依赖图本身的逻辑闭环,根本不在 lock 文件里“存档”,它存在于 composer.json 声明和 autoload 规则构成的解析路径中。
试图用这个命令绕过 cycle detected 报错,等于拿一张没改过的地图去导航一条不存在的路。它不会让 A→B→A 变成单向,只会更快地告诉你“这条路还是走不通”。
真正该做的是:删掉 vendor/ 和 composer.lock 后跑 composer update --dry-run,观察第一步失败点;或临时注释掉 require-dev 下所有非核心工具(如 phpunit/phpunit、phpstan/phpstan),再试一次——很多“循环”其实是测试工具反向加载了你的 src/。
抽离契约时最容易踩的三个坑
新建的契约包(如 myorg/contracts)必须满足三个条件,缺一不可:
-
"require": {}必须为空——哪怕只加一个php版本约束,都可能被下游包当作可传递依赖拉进来,重新闭合路径 -
"autoload": {"psr-4": {"MyOrg\Contracts\": "src/"}}中的命名空间必须独立,不能与myorg/core或myorg/api的 autoload 范围重叠,否则 Composer 会把契约类当成主包的一部分解析 -
myorg/core和myorg/api的composer.json中,必须彻底删掉彼此的require,只保留对myorg/contracts的引用;别用replace或provide去伪装兼容——这会让 autoload 错乱,运行时报Class not found
隐式循环比显式 require 更难发现
最常被忽略的循环来源是 autoload 与 require-dev 的组合。比如你在 "autoload": {"psr-4": {"App\": "src/"}} 下写了 use PHPUnit\Framework\TestCase;,而 src/ 又被生产 autoload 覆盖,Composer 就会把 phpunit/phpunit 当作生产依赖来解析,哪怕它只在 require-dev 里声明。
检查方法很简单:
- 确认
autoload-dev是否只覆盖tests/或stubs/,且命名空间不与autoload重叠 - 运行
composer dump-autoload --no-dev后,手动require一个业务类,看是否仍能加载到phpunit相关类——如果能,说明 autoload 泄露了 - 用
composer show --tree查看phpunit/phpunit是被谁引入的,如果路径里出现了你的主包名,那就是隐式循环实锤
这类问题往往本地能装上、CI 上失败,因为 CI 环境没有缓存旧版元数据,会严格按当前 autoload 配置重新解析整个图——而你本地可能还残留着旧 lock 文件的“惯性”。











