Composer 依赖死循环表现为 --dry-run -v 末尾反复出现同一路径,CPU 和内存飙升,需用 composer depends --tree 结合有效 composer.lock 实锤闭环,并检查 require-dev 的 autoload 跨目录加载。

composer update --dry-run -v 末尾反复出现同一路径就是死循环信号
Composer 不会“尝试解开”循环依赖,它直接在 SAT 求解器里无限回溯,表现为 CPU 持续 95%+、内存每秒涨 50MB+、终端卡住几十秒后突然退出。关键不是看开头报错,而是盯 composer update --dry-run -v 输出的最后 5–10 行:
- 反复出现同一组跳转(如
vendor/a → vendor/b → vendor/a)是强闭环证据 - 看到
Rejecting vendor/b because it requires vendor/a ^2.0, while locked at vendor/a:1.9,说明环内版本互相拉扯 - 某个包名(如
myorg/core)连续出现 ≥3 次且父级不同,大概率是 A→C→B→A 类多跳环
composer depends --tree 是唯一能实锤闭环的命令
它不查“谁依赖我”,而是按箭头方向展示“被谁依赖”,自身重复出现即闭环成立。但有两个硬前提必须满足:
- 项目已有有效的
composer.lock(否则composer depends myorg/core --tree只显示一级依赖,完全漏掉间接环) - 命令必须带
--tree参数(只跑composer depends myorg/core等于白查) - 若报
Package not found,说明该包根本没进composer.lock——先删vendor/和composer.lock,再跑composer update --dry-run -v看第一步失败点
require-dev 中的 autoload 是 80% 隐式循环的源头
最常被忽略的“循环”根本不在 require 字段里,而是测试工具通过自动加载把你的代码反向拉进了它的运行时上下文:
- 检查所有
require-dev包的composer.json,重点搜../、../../、src/、tests/——比如phpunit/phpunit的"autoload": {"psr-4": {"App\": "../src/"}}就会让它加载你的src/ - 确认你自己的
autoload-dev没把vendor/下的路径写进去,这等于告诉 Composer:“我依赖我自己” - 临时注释掉
phpunit/phpunit、infection/infection等喜欢跨目录autoload的 dev 工具,再试composer update --dry-run -v;如果不再反复跳转,问题就在这
抽离契约包、运行时解耦、降级为可选依赖是三种结构破环方式
别指望靠改版本号或加 conflict 规则绕过,那只是掩盖求解器失败。真正破环要动结构:
-
抽离契约包:把
myorg/core和myorg/api共用的接口/异常/DTO 提炼成独立的myorg/contracts,双方都require它,不再互相引用 -
运行时解耦:把
myorg/api对myorg/core的硬依赖改为通过Container::get()或接口注入,require改成require-dev或彻底移除 -
降级为可选依赖:在
myorg/core的composer.json中,把"myorg/api": "^2.0"移到require-dev,并在代码里用class_exists()做存在性判断
复杂点在于:很多环藏在 autoload-dev 路径和测试工具的跨目录加载里,不 grep 所有 composer.json 就永远找不到根因。











