直接用composer拆包行不通,因业务模块依赖框架上下文;真正可行的是path仓库+精准psr-4映射+接口契约,实现物理隔离、加载可控、改代码即生效。

直接用 Composer 把单体应用“拆包”行不通,不是工具不行,而是业务模块天然依赖框架上下文——硬打包会导致 Class not found、容器绑定失败、本地开发卡在 dump-autoload 上。真正能落地的解耦,是用 path 仓库 + 精准 PSR-4 映射 + 接口契约,让模块物理隔离、加载可控、改代码即生效。
为什么不能直接 composer require 自己的业务模块?
业务模块不是工具库,它活在 Laravel 或 Symfony 的运行时里:要访问 request()、调用 event()、读 config('app.debug')、依赖数据库连接和中间件栈。一旦打成 Packagist 包:
• IlluminateContractsContainerBindingResolutionException 是常态,因为 vendor 里的包没注册进主应用容器
• 每次改 src/OrderService.php 都得 composer update app/order-module + 清缓存 + 重启队列
• autoload-dev 和主项目 PSR-4 映射重叠,dump-autoload 后类找不到是大概率事件
• 测试必须启动整个 Web 应用,CI 单次跑测试动辄 3 分钟起步
path 仓库配置必须满足的三个硬条件
缺一不可,否则 vendor/ 下不会生成软链接,模块根本不会被加载:
• repositories 中的 url 必须指向**含合法 composer.json 的具体子目录**(如 "./modules/order"),不能只写 "./modules/*"(除非你用的是 Composer 2.2+ 且每个子目录都有 composer.json)
• 子模块的 composer.json 必须有非空 name 字段,且格式为 "myapp/order-module",和根项目 require 中的包名**完全一致**
• 根项目的 require 里不能写 "myapp/order-module": "^1.0" 这类带版本约束的写法——本地 path 包不走版本解析,必须统一用 "@dev" 或 "*"
autoload 怎么配才不炸?命名空间必须收敛
这是最容易踩的坑:所有模块都写 "psr-4": {"App": "src/"},最后只有最后一个生效,App\Models\User 可能从订单模块里加载,而不是用户模块。
• 每个模块的 autoload 必须收敛到自身命名空间,例如订单模块写:"MyApp\Order\": "src/"
• 绝对禁止在模块内硬编码引用主项目命名空间(如 new App\Models\User),契约只能通过接口或 DTO 传递
• 模块的 composer.json 里不要出现 require-dev 或测试框架依赖(如 phpunit/phpunit),否则会污染主项目 vendor/
• 执行 composer update myapp/order-module 后,Composer 会在 vendor/myapp/order-module 创建符号链接,既保持物理隔离,又享受自动加载
模块间通信只能靠接口,不能 new 具体类
解耦成败就卡在这一步:如果 PaymentService 直接 new User 或调用 Auth::user(),那模块就是假拆分,后续根本没法单独复用或替换。
• 先定义接口,比如 UserRepositoryInterface 放在公共契约包里
• 主应用或模块内提供实现,但实现类不能被其他模块直接 new 或静态调用
• 所有跨模块调用走 Laravel 容器解析(app(UserRepositoryInterface::class))或消息总线(event(new OrderPlaced($order)))
• 模块内禁止使用 Laravel Facade(如 Cache::get()),改用注入的 contract 实例
最复杂的点不在配置,而在边界识别和接口设计——一个“用户模块”到底该暴露哪些能力?它的状态是否允许被订单模块直接修改?这些不是 Composer 能解决的,得靠领域建模和团队对齐。工具只是把契约落地的载体,别让它替人做决策。











