Composer虚拟包是仅在composer.json中声明、无实际源码的占位契约,通过provide字段让模块安全依赖接口(如"mycompany/payment-interface"),解决单体拆分中接口抽象层尚未独立发布时的解耦过渡问题。

什么是 Composer 虚拟包,它真能解决拆分时的“接口依赖”问题?
虚拟包(virtual package)不是真实存在的代码包,而是一个仅在 composer.json 中声明的、name 存在但无实际源码的包名。它的核心作用是「占位 + 契约声明」:让一个模块(比如订单服务)能安全地 require "mycompany/payment-interface": "^1.0",而不必绑定到某个具体实现(如微信支付 SDK),哪怕这个接口包当前还没发布成独立仓库。
它在单体拆分中不是“替代方案”,而是解耦过渡期的必要缓冲——当你还不能立刻把「支付抽象层」抽成独立 Git 仓库和 Packagist 包时,先用虚拟包锁定命名空间和版本契约,避免其他模块直接 new 实现类或硬编码 config。
- 必须配合
provide字段使用:只有真实包通过"provide": { "mycompany/payment-interface": "^1.0" }声明自己提供了该虚拟包,Composer 才会认为依赖已满足 - 虚拟包本身不包含任何文件,也不需要
autoload;它只存在于依赖图谱中 - 不能单独
composer require一个纯虚拟包——它没有源,Composer 会报Could not find a matching version
如何在单体项目里声明并使用虚拟包?
你不需要为虚拟包建新目录或写 PHP 类。只需在单体项目的 composer.json 根节点下加一段 provide:
{
"name": "mycompany/monolith",
"provide": {
"mycompany/user-contract": "^2.0",
"mycompany/notification-interface": "^1.1"
},
"require": {
"php": "^8.1"
}
}
这样,当其他正在拆分中的模块(比如新建的 mycompany/order-service)在自己的 composer.json 中写 "require": { "mycompany/user-contract": "^2.0" },Composer 就能解析成功,不会报错找不到包。
-
provide必须写在主项目(或已有包)的根composer.json里,不能写在repositories或config下 - 版本号要与未来真实接口包的版本对齐,否则后续替换时会触发 Composer 版本冲突
- 别滥用:只用于明确要拆、但尚未完成抽离的契约层,比如
*-contract、*-interface、*-dto这类纯定义包
虚拟包和真实包切换时最容易踩的坑
从“虚拟占位”切换到“真实发布”,表面只是换了个 Git 仓库,实际容易卡在依赖解析失败上。
- 真实包发布后,必须确保其
name和provide完全一致,比如"name": "mycompany/user-contract"且"provide": { "mycompany/user-contract": "^2.0" }——少一个引号或拼错字母,Composer 就认不出来 - 主项目里原来的
provide必须删掉,否则 Composer 会同时看到“虚拟提供”和“真实包提供”,可能选错版本或报Package ... is already provided by ... - 执行
composer update mycompany/user-contract前,先确认私有源已启用且"packagist.org": false已配置,否则 Composer 仍会去 packagist.org 查找,返回 404 后静默跳过你的私有包 - 真实包的
autoload必须正确映射接口路径,例如"psr-4": { "MyCompany\User\Contract\": "src/Contract/" },否则类加载失败,跟虚拟包是否生效无关
为什么不用 interface-first + 直接 require 主项目,而要用虚拟包?
因为直接 require "mycompany/monolith": "dev-main" 会导致强耦合和版本混乱:订单包被绑死在单体主干分支上,无法独立发版,也无法做灰度验证。
虚拟包把「契约存在性」和「实现来源」彻底分开——订单包只认 mycompany/user-contract 这个名字和语义化版本,至于它来自单体项目的 provide,还是来自私有仓库里的独立 .git,对订单包完全透明。
真正难的不是声明虚拟包,而是团队是否同步了这个契约:所有涉及该接口的 PR 都得检查是否符合 mycompany/user-contract 的方法签名,否则就算 Composer 解析通过,运行时也会 Fatal error: Interface '...' not found。


















