做了几年 PHP 的人,对composer require这行命令都熟得不能再熟,每天装别人的包跟吃饭一样自然。但真要自己写一个 Composer 包、把它发布到 Packagist 上让别人也能 require,不少老接触反而会卡壳——不是业务代码不会写,而是 composer.json 的字段配置、PSR-4 自动加载的映射规则、版本 tag 的发布逻辑这些"外围"的东西,平时根本没机会碰。这篇文章就用一个最简单的 Hello World Composer 扩展包,把完整流程从头到尾走一遍:从设计包名和命名空间、写 composer.json,到本地用 path 仓库模拟真实消费,再补上测试和文档,最后推到 GitHub、发布到 Packagist。每一步都是可复现的,适合所有想把内部代码沉淀成公共包、或者刚接触 PHP 包开发的开发者参考。
1. 先搞懂三件事:Composer、Packagist 和"包"的本质
1.1 一个 Composer 包到底由什么组成
很多人把"写包"想得很玄,其实包的本质就是一个带 composer.json 的目录。composer.json 是包的身份证加说明书,里面写清楚了包叫什么名字、依赖什么、代码如何自动加载。Composer 负责读这张说明书,Packagist 则是一个公共的"包通讯录",它记录了有哪些包、每个包有哪些版本、源码存放在哪里。
使用者执行composer require acme/hello-world时,Composer 会先去 Packagist 查这个包的版本列表,然后从对应的 Git 仓库拉取源码归档放进 vendor 目录。整个链路其实就是"配置声明 + 源码仓库"的组合,没有任何魔法。
搞清楚这个链路,很多问题就能想通了。比如为什么包在本地跑得好好的,一发布别人装完就报 class not found?大概率是自动加载配置写错了,跟业务代码一点关系都没有。所以这篇内容的重点不在"Hello World 怎么打印",而是把包这个容器做对,让后面所有的依赖解析和自动加载都顺畅。
1.2 动手前需要准备的环境
写包和本地测试,只需要常规的 PHP 开发环境;发布环节需要一个 Git 远程仓库托管平台账号和 Packagist 账号。我实际用下来,最少需要准备这些:
- PHP 7.4 以上,建议直接上 8.1 或 8.2。包的
require.php约束会声明兼容的下限,但你自己开发调试时用新版本会省很多事。 - Composer 2.x。装没装可以用
composer --version确认,看到 2.x 版本号就没问题。 - 一个 Git 远程仓库托管平台账号。GitHub 最常用,因为 Packagist 支持直接用这个账号 OAuth 登录,省去单独注册一个账号的麻烦。
- 能正常访问 GitHub 和 Packagist。发布这一步绕不开这两个平台,本地开发和测试则不受影响。
Composer 没装的话,用官方方式下载安装即可。Windows 用户用官方安装器,macOS 和 Linux 用户可以命令行下载。装完之后随便进一个目录跑composer --version,能输出版本就说明环境通了。
再提醒一句:如果你是纯本地演示,可以先把发布环节放一边,path 仓库验证就够用了。但只要你后面想把包给全世界用,Git 远程仓库和 Packagist 这两步迟早要过,不如一次到位。
2. 动手前的设计:包名、命名空间与目录规划
2.1 包名的规则:为什么 hello_world 会翻车
Composer 包名由两部分组成,格式是vendor/package,中间用斜杠分隔。vendor 一般用你的账号名或者组织名,package 用功能名。最经典的例子就是monolog/monolog这种,前面是作者、后面是项目名。
包名的书写规则是:小写字母、数字、中划线。不要用大写字母,不要用下划线。很多新手第一个坑就踩在这里,本地起了个Acme/Hello_World,结果提交到 Packagist 被驳回,或者别人composer require的时候大小写对不上直接报错。这不是玄学,是 Composer 包名 schema 的硬性约束。
选名还有一个经验:发布前去 Packagist 搜索一下想用的名字是否已被占用。php、composer、symfony这类通用词根就别碰了,既容易撞车也不专业。我自己一般会让包名一眼看去就能猜到用途,比如acme/hello-world,读出来就是"某个作者的 Hello World 演示包"。
2.2 PSR-4 映射:自动加载的底层逻辑
Composer 的 autoload 字段支持 psr-4、psr-0、classmap、files 几种方式,现在社区标准基本是 PSR-4。PSR-4 的规则用一句话就能说清楚:命名空间前缀对应目录前缀,命名空间里的反斜杠对应目录里的斜杠。
"autoload": { "psr-4": { "Acme\\HelloWorld\\": "src/" } }上面这段配置的意思是:所有Acme\HelloWorld\开头的类,都去src/目录下找。所以类名Acme\HelloWorld\HelloWorld,对应的文件路径就是src/HelloWorld.php。命名空间层级和目录层级是一一映射的。
这里有两个细节特别容易出问题。第一,JSON 里的反斜杠必须写成\\,这是 JSON 的转义规则,漏一个就等着 Composer 报错。第二,映射的键必须以反斜杠结尾,值必须以斜杠结尾。写成"Acme\\HelloWorld": "src"这样没有尾部斜杠的,Composer 会警告,极端情况下加载还会错位。
补一个进阶知识:composer dump-autoload -o可以生成优化过的授权类映射,把类的绝对路径提前写入文件,生产环境加载会快一些。如果确定代码里没有动态类名,还可以用--classmap-authoritative更进一步,代价是一旦漏了某个类就真的找不到了,所以别随便用。
2.3 目录结构:参考社区惯例,不搞特殊
包目录的布局虽然不强制,但社区惯例是最稳妥的选择:
hello-world/ ├── src/ # 源码 ├── tests/ # 测试 ├── composer.json ├── README.md └── LICENSEsrc放业务代码,tests放测试代码,这两个目录名是 PHP 生态约定俗成的,别人一眼就能看懂。LICENSE最好从一开始就放进去,没有协议文件的包在 Packagist 上会显示无许可证,很多谨慎的使用者根本不会装。
.gitignore也要建好,把/vendor/和composer.lock忽略掉。库包发布出去,使用者各自生成自己的 vendor 和 lock 文件,你提交这两样东西只会制造混乱。
2.4 composer.json 关键字段逐项解读
先看一份完整的初始配置,后面逐一解释:
{ "name": "acme/hello-world", "description": "一个简单易用的 Hello World Composer 扩展包,用于演示如何创建和发布 PHP Composer 包", "type": "library", "license": "MIT", "require": { "php": "^7.4 || ^8.0" }, "autoload": { "psr-4": { "Acme\\HelloWorld\\": "src/" } }, "autoload-dev": { "psr-4": { "Acme\\HelloWorld\\Tests\\": "tests/" } }, "require-dev": { "phpunit/phpunit": "^9.6 || ^10.5" } }name:包的唯一标识,一旦发布到 Packagist 就不好改了,想清楚再定。description:一句话说明,别人在 Packagist 搜索时主要靠它判断你的包是干嘛的,尽量写清楚,别写"my package"这种废话。type:库包填library就够了,这是默认值,不填也行。license:必须用 SPDX 标准的写法,MIT最常见。写错了composer validate会报警。require:声明 PHP 版本兼容范围。^7.4 || ^8.0表示兼容 7.4 及以上、8.0 及以上,用法说明见后文语义化版本部分。autoload:给使用者看的,发布后别人加载的就是这份配置。autoload-dev:只在你本地开发时生效,测试代码的命名空间映射放在这里。require-dev:PHPUnit 这类开发工具,发布时不会传给使用者。
3. 核心代码实现与本地验证
3.1 写一个结构规范的 HelloWorld 类
前面设计定了,写代码反而是最简单的部分。在src目录下新建HelloWorld.php:
<?php declare(strict_types=1); namespace Acme\HelloWorld; class HelloWorld { public function greet(string $name = 'World'): string { return sprintf('Hello, %s!', $name); } }几个设计点值得说一下。为什么用实例方法而不是静态方法?静态方法在后期要扩展状态、做依赖注入时会很别扭,而且不好 mock。Hello World 虽然简单,但结构上演示一个以后能长大的类的正确开局,比图省事写成静态要好。
string类型的参数和返回值都写清楚,调用方拿到的一定是字符串,拼接输出不会出幺蛾子。declare(strict_types=1)是现代 PHP 包的好习惯,它只作用于当前文件内部的函数调用,不会强制使用这个包的人,所以放心写。
3.2 用 path 仓库在本地模拟真实消费
写包别急着发布,先在本地用一个消费项目验证。我在包的同级目录建一个test-consumer,结构大概是:
demo/ ├── hello-world/ # 我们要发布的包 └── test-consumer/ # 模拟用户test-consumer/composer.json这样写:
{ "name": "acme/consumer-demo", "repositories": [ { "type": "path", "url": "../hello-world", "options": { "versions": { "acme/hello-world": "1.0.0" } } } ], "require": { "acme/hello-world": "^1.0" } }然后执行:
composer updatetype为path的仓库会直接从本地目录安装包,默认采用符号链接方式。也就是说你改了hello-world/src下的代码,test-consumer/vendor里立即生效,非常适合开发期反复调试。
options.versions是个关键细节。如果不配置这项,path 仓库会去读源码仓库当前分支作为版本,比如dev-main,而require里写的是^1.0,约束解析不上,Composer 就会报"找不到匹配版本"。手动指定1.0.0之后,Composer 认为本地包就是这个版本,完美满足约束。
验证代码写一个入口文件:
<?php require __DIR__ . '/vendor/autoload.php'; use Acme\HelloWorld\HelloWorld; echo (new HelloWorld())->greet('张三');能输出Hello, 张三!,就说明自动加载、包解析、代码执行全部都通了。
3.3 path 仓库的高级用法与注意点
path 仓库默认符号链接这个特性,对开发是便利,但偶尔也会误导。如果你在包里打了一个发布用的压缩包,或者在本地测试的是"发布形态"的代码,符号链接会让你看到的是源码实时状态,而不是包归档里真实的文件快照。
这时候可以在 repositories 配置里加:
"options": { "symlink": false }关掉符号链接后,Composer 会真实复制一份文件到 vendor,行为和从 Packagist 拉取 dist 包更接近。我在开发一些带构建产物的包时就用这个方式验证,避免出现"本地跑得好好的,线上就坏"的诡异情况。
还有个小经验:composer update之后如果改了包里的代码,记得在test-consumer里重新跑一次composer dump-autoload,尤其是新增文件的时候。符号链接能让文件内容实时同步,但 Composer 生成的加载映射文件不会自动感知新类,不重新生成就会报 class not found。
4. 测试、校验与文档:发布前的质量关卡
4.1 用 PHPUnit 把行为钉死
包一旦发布,使用者就会拿它当作依赖,你写进去的每个行为都会被无数个项目依赖。为了对自己负责,也给使用者信心,测试不能省。在tests目录写一个最简单的测试:
<?php declare(strict_types=1); namespace Acme\HelloWorld\Tests; use Acme\HelloWorld\HelloWorld; use PHPUnit\Framework\TestCase; final class HelloWorldTest extends TestCase { public function testGreetWithDefaultName(): void { $this->assertSame('Hello, World!', (new HelloWorld())->greet()); } public function testGreetWithCustomName(): void { $this->assertSame('Hello, 张三!', (new HelloWorld())->greet('张三')); } }在包根目录先跑composer install把 PHPUnit 装进 vendor,然后执行:
vendor/bin/phpunit --bootstrap vendor/autoload.php tests看到两个 test 全绿,说明默认参数和自定义参数两条路径都符合预期。为什么手动传--bootstrap vendor/autoload.php?因为 PHPUnit 需要先加载自动加载器才能找到被测类和测试类。如果在项目里放一份phpunit.xml声明 bootstrap,就可以直接跑vendor/bin/phpunit,更省事。对于正式一点的项目,我建议把这份配置文件补上,团队协作时不用靠口头交代命令。
4.2 composer validate 与仓库卫生
代码写完了,跑一下官方校验:
composer validate这条命令会检查 composer.json 是否符合 schema 规范:包名是否合法、字段是否齐全、license 是否有效、必填项有没有漏。有任何问题它会明确指出来,按提示改就行。
除了校验,我还会在发布前做一次"仓库卫生检查":
.gitignore里确认忽略/vendor/和composer.lock。- 把
composer.lock留在仓库里的库包,我见过不少次,纯粹制造噪音。 - 检查一下有没有把
.env、密钥之类的文件误提交,库包是公开的,泄密就是事故。
这些检查花不了两分钟,但能避免很多发布之后才后悔的场面。
4.3 README 与 LICENSE:信任的第一道门槛
Packagist 页面会自动渲染仓库里的 README,这是你包的"门面"。一段清楚到能"抄作业"的 README 应该包含:安装命令、最小使用示例、运行测试的方法、许可证。
# hello-world 一个简单易用的 Hello World Composer 扩展包,用于演示如何创建和发布 PHP Composer 包。 ## 安装 composer require acme/hello-world ## 使用 ```php use Acme\HelloWorld\HelloWorld; echo (new HelloWorld())->greet('张三');测试
composer install vendor/bin/phpunit --bootstrap vendor/autoload.php tests
LICENSE 文件同样重要。MIT 协议模板网上很多,把年份和版权人填上就行。没有 LICENSE 的包,商业项目通常不敢用,因为授权关系不明确。这一步不算技术难点,但直接决定你的包能否被广泛采用。 ## 5. 发布到 Packagist:一次完整的对外交付 ### 5.1 推到 GitHub 并打上语义化版本 tag 本地验证全绿,下一步是把代码推到远程仓库。在 GitHub 建好仓库后执行: ```bash git init git add . git commit -m "feat: initial hello-world package" git remote add origin git@github.com:acme/hello-world.git git push -u origin main然后是最关键的一步:打 tag。
git tag v1.0.0 git push origin v1.0.0Packagist 的版本信息完全来自 Git 的 tag,不 tag 就永远没有稳定版本。tag 的命名规则是v加语义化版本号,比如v1.0.0,不要随便打v1或者1.0,Composer 解析不了。
这里顺便把语义化版本说清楚:MAJOR.MINOR.PATCH三段,主版本号在 API 破坏性变更时递增,次版本号在向后兼容的新功能时递增,补丁版本号在向后兼容的 bug 修复时递增。你修了一个 typo 就发v1.0.1,加了新功能不破坏老接口发v1.1.0,改了方法签名这种破坏性变化必须发v2.0.0。
5.2 Packagist 提交与自动更新配置
代码推上去、tag 打好之后,打开 Packagist 官网,用 GitHub 账号登录。登录后页面右上角有 Submit Package 入口,粘贴 GitHub 仓库的完整地址,Packagist 会立刻克隆仓库并校验 composer.json。校验通过,你的包就上线了,页面上能看到 v1.0.0 的版本条目。
从这一步开始,你的包已经可以被全世界的 PHP 项目通过composer require acme/hello-world安装了。影响范围从本地验证一下子变成公共依赖,对应的责任也来了:别人在 issue 区反馈问题你得处理,兼容性破坏得上大版本,安全问题要第一时间修。
以后每次更新版本,手动流程是"改代码 -> commit -> 打新 tag -> push -> 回 Packagist 点 Update"。手动最大的问题是容易忘,我见过不止一次:代码推了,tag 打了,结果用户composer update死活拿不到新版本,最后发现 Packagist 上根本没同步。
所以强烈建议配自动更新。在 GitHub 仓库的 Settings -> Webhooks 里添加一个 webhook,payload URL 和 secret 用 Packagist 包管理页给出的那一套值。之后每次推送新 tag,GitHub 会通知 Packagist 自动拉取,新版本秒级上线。
5.3 版本约束与使用者体验的配合
发布之后,别人怎么引用你的包,取决于约束符的写法。这几种最常用:
^1.0:允许所有>=1.0且<2.0的版本,这是最常用的语义化约束。~1.2.3:允许>=1.2.3且<1.3.0,只接受补丁更新,更保守。1.0.0:锁定精确版本,控制器项目里常见。
如果你的 v1.0.0 和 v1.0.1 行为有细微差异,但^1.0的使用者会在composer update时自动升到 v1.0.1。所以语义化版本的纪律特别重要:兼容性没把握的改动,宁可升次版本甚至主版本,也别在补丁里塞私货。升级主版本是一次破坏性发布,用户至少能看到^1.0约束冲突,提前知道要改代码。
另外,想要让用户能提前体验开发版,可以让他们用dev-main并要求在消费项目里设置"minimum-stability": "dev"。这个通常在 README 里写清楚,不然没人敢用非稳定版本。
6. 高频问题排查与实测避坑记录
6.1 高频报错速查表
把我在实际操作中遇到过的典型问题整理成一张表,方便你直接对照:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 别人安装后报 class not found | PSR-4 映射或文件路径大小写不匹配 | 核对命名空间与目录,重新composer dump-autoload -o |
| path 仓库本地 require 找不到版本 | 未指定options.versions,默认变成 dev-main | 加options.versions或改用dev-main@dev约束 |
| Packagist 提交被驳回 | 包名含大写或下划线 | 改成小写字母和中划线组合 |
| Packagist 页面上没有版本号 | 没打 tag,或 tag 名不符合v*规范 | git tag v1.0.0并推送 |
用户composer update拿不到新版本 | Packagist 未同步,或本地缓存了旧 dist | 手动点 Update / 配 webhook /composer clear-cache |
composer validate报 license 无效 | license 字段不是 SPDX 标识 | 改成标准值,如MIT |
| 新增类后本地一直报 class not found | autoload 映射文件没重新生成 | 重新跑composer dump-autoload |
6.2 发布后才明白的几个经验
第一,包版本一变,影响是全局的。你发一个带 bug 的 v1.0.1,所有^1.0约束的项目在下次 update 时可能全部被波及。所以发布前哪怕只是改了一行字符串,也要跑一遍完整测试,固定流程不能省。
第二,Packagist 包名一旦注册,改名的成本极高。不只是链接变更,老版本历史、引用你包的项目、搜索引擎索引全都得跟着动。取名字的时候多花五分钟搜索,后面能省无数麻烦。
第三,README 里的示例代码要能直接复制运行。我在实际维护某个模拟项目包时就发现,使用者不会先读文档再动手,他们都是复制示例代码跑一下,成功了才看细节。示例如果有隐藏依赖或过时 API,就等于在门口劝退用户。
第四,别忘了版本号本身也会过期。你写"php": "^7.4 || ^8.0"这个约束,几年后 PHP 9 出来了,老版本约束会阻止新环境安装你的包。保持包依赖与时俱进,和写业务代码一样需要持续维护。
最后再分享一个我个人的习惯:包里除了一开始就放好的测试,我会在composer.json里固定"minimum-stability": "stable",因为它提醒我发布的版本必须是稳定的、经过验证的。而这个 Hello World 包的核心价值,恰恰不在于那两行Hello字符串,而在于它把"声明 - 加载 - 验证 - 发布"的完整链条演示了一遍。跑通这条链路之后,后面任何正经的工具包、SDK 包,都只是往src/里填代码的事了。