项目写到一定规模,你总会发现有些功能在多个项目里反复出现:短信发送、支付对接、内部接口签名校验等等。每次复制粘贴一份代码不仅麻烦,升级时更是灾难。把这些功能抽成独立的Laravel扩展包,用Composer管理,才是正确的工程化做法。这篇文章会按照一个完整包的开发流程,把关键环节逐一讲透,看完你就能动手写自己的第一个包了。

一、扩展包的目录结构与composer.json配置
一个规范的Laravel包首先是标准的Composer包,骨架通常包含src源码目录、config配置目录、tests测试目录,以及路由、迁移等资源目录。假设我们要开发一个叫laravel-sms的短信包,目录结构可以这样组织:
laravel-sms/ ├── src/ │ ├── SmsServiceProvider.php │ ├── Sms.php │ ├── Facades/ │ │ └── Sms.php │ └── Commands/ │ └── SmsTestCommand.php ├── config/ │ └── sms.php ├── routes/ │ └── web.php ├── tests/ ├── composer.json └── README.md
composer.json是包的身份证,除了常规的name、description、require字段,最关键的是autoload和extra两个配置。autoload里的psr-4声明决定了你的命名空间和目录的对应关系,extra.laravel则用来配置包的自动发现,有了它用户安装后不需要手动在config/app.php里注册服务提供者。
{
"name": "yourname/laravel-sms",
"description": "一个简洁的短信发送扩展包",
"require": {
"php": ">=8.0",
"laravel/framework": ">=10.0"
},
"autoload": {
"psr-4": {
"YourName\\LaravelSms\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"YourName\\LaravelSms\\SmsServiceProvider"
],
"aliases": {
"Sms": "YourName\\LaravelSms\\Facades\\Sms"
}
}
}
}
这里有个容易踩的坑:extra里的providers必须写完整的类名包括命名空间,漏写或多写反斜杠都会导致自动发现失效。写完composer.json后在包根目录执行composer dump-autoload,命名空间映射才会生效。
二、ServiceProvider:包的启动入口
ServiceProvider是整个扩展包的核心,Laravel安装包后第一次解析容器时就会执行它的register和boot两个方法。register方法里只做容器绑定,不要在这里使用数据库、路由等服务,因为此时其他服务可能还没加载;boot方法则在所有包注册完成后执行,可以安全地加载路由、视图、迁移等资源。
<?php
namespace YourName\LaravelSms;
use Illuminate\Support\ServiceProvider;
class SmsServiceProvider extends ServiceProvider
{
public function register(): void
{
// 合并配置:用户没发布配置文件时使用包内的默认值
$this->mergeConfigFrom(__DIR__ . '/../config/sms.php', 'sms');
// 绑定接口到实现,方便用户替换为自己的驱动
$this->app->bind(SmsClientInterface::class, AliyunSmsClient::class);
// 单例绑定,整个请求周期只实例化一次
$this->app->singleton('sms', function ($app) {
return new Sms(config('sms'), $app->make(SmsClientInterface::class));
});
}
public function boot(): void
{
// 发布配置文件到应用的config目录
$this->publishes([
__DIR__ . '/../config/sms.php' => config_path('sms.php'),
], 'sms-config');
// 注册命令行指令
if ($this->app->runningInConsole()) {
$this->commands([SmsTestCommand::class]);
}
// 加载包内路由
$this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
}
}
注意mergeConfigFrom和publishes的区别:前者是运行时兜底的默认配置,后者是执行php artisan vendor:publish时把文件复制到用户项目里让用户自由修改。publishes的第二个参数是发布标签,用户可以用php artisan vendor:publish --tag=sms-config只发布这一个配置文件,而不是把包里所有可发布资源全倒出来。
boot方法里如果包包含数据库迁移,用loadMigrationsFrom加载即可,迁移文件会随php artisan migrate一起执行,用户不需要手动拷贝文件。这个设计是Laravel包机制里最贴心的部分之一。
三、Facade门面与配置文件设计
为了让调用方式更Laravel味,我们通常给包配一个Facade,让用户可以用Sms::send()这样简洁的静态调用方式。Facade的原理并不神秘:它继承Illuminate\Support\Facades\Facade,通过getFacadeAccessor返回容器中绑定的key,底层用__callStatic魔术方法把静态调用转发成容器实例的方法调用。
<?php
namespace YourName\LaravelSms\Facades;
use Illuminate\Support\Facades\Facade;
class Sms extends Facade
{
protected static function getFacadeAccessor(): string
{
// 对应容器中 singleton 绑定的 'sms'
return 'sms';
}
}
配置文件的设计建议遵循Laravel自身的习惯,返回一个多维数组,把驱动选择、默认网关、超时时间等分块组织:
<?php
return [
'default' => env('SMS_DRIVER', 'aliyun'),
'timeout' => 10,
'drivers' => [
'aliyun' => [
'access_key' => env('SMS_ALIYUN_KEY'),
'secret' => env('SMS_ALIYUN_SECRET'),
'sign_name' => env('SMS_ALIYUN_SIGN'),
],
],
];
密钥类配置一律通过env读取默认值,但要注意包内部代码不要直接调用env函数,因为用户执行config:cache后env函数会返回null。正确做法是只在配置文件里用env,业务代码统一用config('sms.drivers.aliyun.access_key')读取,这是包质量和业余包之间非常明显的一道分水岭。
四、本地调试与发布到Packagist
包写好了怎么在不发布的情况下测试?最方便的方式是Composer的path类型仓库。在测试项目的composer.json里加入:
{
"repositories": [
{
"type": "path",
"url": "../laravel-sms"
}
],
"require": {
"yourname/laravel-sms": "*"
}
}
执行composer update后,包会以软链接的形式出现在vendor目录,你在包目录里改的任何代码立即在测试项目生效,完全不需要反复更新。这是本地开发包的标准姿势,比反复往GitHub推代码高效得多。
测试通过后就可以发布了。流程很简单:先把代码推送到GitHub并打好tag(例如v1.0.0),然后到Packagist官网提交仓库地址,Packagist会自动抓取composer.json生成包页面。记得在GitHub仓库设置里配置Packagist的Webhook,这样每次push新tag都会自动同步版本。发布后用户只需要一条命令就能安装使用:
composer require yourname/laravel-sms php artisan vendor:publish --tag=sms-config
最后补充几个实践建议:版本号遵循语义化版本规范,破坏性改动升主版本;在README里写清楚安装步骤和配置说明,这是用户对你包的第一印象;写几个基础测试用例,至少保证核心方法不回归。做到这几点,你的包就已经超过社区里一大半的扩展包了。
Laravel PackageLaravel扩展包开发服务提供者修改时间:2026-09-11 20:18:36