前置条件
本代码库中的插件是一个小型 Laravel 包。在搭建之前,请确保您要扩展的宿主 AcelleMail 已在运行,并且本地机器上有可用的 PHP 工具链。以下 CLI 命令假定您处于应用根目录(即包含 artisan 文件的那个目录)。
宿主应用
- AcelleMail v4.x 已安装并对外服务。插件加载器是
App\Providers\AppServiceProvider 的一部分 — 较早的 3.x 版本没有 Plugin::autoloadWithoutDbQuery()。
- 一个队列工作进程、调度器或能够命中应用根的 Web 请求 — 加载器在启动时运行,而非按需运行。
- 对
storage/app/plugins/ 的写权限。Artisan 命令会把脚手架写在这里,而不是 vendor/。
值得复习的 PHP 知识
插件体系重度依赖几项 PHP 与 Laravel 基本功。如果其中任何一项有些生疏,请在搭建前暂停翻一翻相关文档 — 调试一个在 composer.json 中声明了错误命名空间的插件,要比一次性写对难得多。
- PSR-4 自动加载。插件的
composer.json 把命名空间前缀映射到 src/ 目录。AcelleMail 在启动时用一个全新的 Composer\Autoload\ClassLoader 注册该映射 — 因此每个 PHP 文件中的命名空间声明必须与 composer.json 中的映射完全一致,包括大小写。
- 闭包与
use 关键字。几乎每个 Hook 监听器都是一个闭包。当闭包需要外部变量时,必须显式捕获。忘记这一点是插件代码里 undefined variable 错误最常见的来源。
- ServiceProvider 上的
register() 与 boot()。Laravel 先运行每个 provider 的 register(),再运行每个 provider 的 boot()。在 register() 中登记的 Hook 可能在其依赖就绪之前运行;在 boot() 中登记的 Hook 对翻译收集器而言又太晚了。两者都是真正的坑 — 详见 七个首日错误。
- Eloquent、Blade、路由、Facade。插件迁移使用标准的
Schema 构造器,插件视图就是普通的 Blade 文件,插件路由使用 Route::group(...)。插件没有任何特殊之处 — 生成的文件就是原汁原味的 Laravel。
您不需要把插件发布到 Packagist,也不需要在插件目录里运行 composer install,更不需要在宿主的根 composer.json 中登记任何东西。运行时加载器会处理每一步。
命名规则 — 读一遍即可省下一小时
每个插件都有一个形如 {vendor}/{name} 的身份 — 例如 Aurius、aix/sample、athena/evs。这个身份是数据库 plugins 表、storage/app/plugins/ 目录、storage/app/plugins/index.json 主文件以及生命周期 Hook 名(activate_plugin_{vendor}/{name}、delete_plugin_{vendor}/{name}、icon_url_{vendor}/{name})中的规范键。
App\Model\Plugin::init() 中的校验器执行一套小而保守的规则(规范正则:^[a-z0-9]+\/[a-z0-9]+$,两侧均 min:2 max:32):
- 仅小写字母与数字。不允许下划线、连字符、大写字母。早期允许下划线的指引已被取代 — 如果您在旧 README 中看到
my_plugin,那已经无效。
- 两侧各 2 到 32 个字符。
a/sample 不通过(vendor 太短);team/x 不通过(name 太短)。
- 仅一个斜杠。vendor 与 name。不允许嵌套。
保守交集规则源自 2026-04 的一次清理,把 Plugin::init() 与 Plugin::getStoragePathByName() 对齐。两个校验器现在认同同一个正则 — 不再可能出现某个名字能干净地搭起来却无法加载的情况。
谨慎选择 vendor 段。vendor 出现在每个命名空间、插件 routes.php 中的每个 URL 前缀,以及插件发出的每个翻译键里。事后改名意味着要在每个文件里做搜索替换。acmecorp/loyalty 含义明确;x/loyalty 不通过(vendor 太短);acmecorp/loyaltypoints 没问题。
搭建命令
在应用根目录运行:
php artisan plugin:init {vendor}/{name}
我们以 acmecorp/loyalty 作为示例 — 本页的其余部分都假设使用该名称。您自己运行命令时替换为您自己的名称即可。
$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder
成功消息由 App\Console\Commands\InitPlugin 打印,它是模型层方法 App\Model\Plugin::init($name) 的薄包装。该方法做了本页其余部分描述的全部事情 — 校验、脚手架复制、Twig 渲染、文件重命名,然后链式调用 Plugin::register($name) 插入数据库行并启动 ServiceProvider。
当提示符返回时,插件已经以未激活状态加载到运行中的应用里。其 routes.php 声明的路由可达,视图可渲染,ServiceProvider 注册的任何 Hook 都已生效。激活唯一会增加的,是插件作者绑定到 activate_plugin_{vendor}/{name} 事件上的内容 — 通常是一次迁移运行。
生成了什么
Artisan 命令在 storage/app/plugins/{vendor}/{name}/ 下写入一小组启动文件,在其中渲染 Twig 占位符,并对占位符迁移文件重命名。文件清单在 Plugin::init() 中硬编码 — 八个内容渲染文件加上几个静态资源。这些文件没有任何特殊之处;它们就是普通的 Laravel,您可以随意删除、改名或扩展。
命令完成后磁盘上的目录树:
storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│ └── migrations/
│ └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│ ├── lang/
│ │ └── en/
│ │ └── messages.php
│ └── views/
│ └── index.blade.php
└── src/
├── Controllers/
│ └── DashboardController.php
├── Models/
│ └── Setting.php
└── ServiceProvider.php
八个文件一览
| 文件 | 用途 |
composer.json | 运行时契约:name、autoload.psr-4 与 extra.laravel.providers 都是必填。缺少它们,加载器无法注册命名空间或启动 provider。 |
src/ServiceProvider.php | Laravel 唯一看见的入口点。在 register() 中注册翻译,在 boot() 中注册路由、视图、生命周期 Hook 与图标 URL。 |
src/Controllers/DashboardController.php | 一次性示例。返回内置的 index.blade.php 视图。可随意替换。 |
src/Models/Setting.php | 绑定到插件首个迁移的 Eloquent 模型。表名按命名空间形式取作 {vendor}_{name}_settings,从而插件之间在同一个数据库里不会冲突。 |
routes.php | 由 ServiceProvider 加载。同时声明图标服务路由(管理员的 Plugins 页使用)与一个示例 plugins/{vendor}/{name} 仪表盘路由。 |
resources/views/index.blade.php | 由 DashboardController 渲染的 Hello World 视图。替换为您真正的 UI。 |
resources/lang/en/messages.php | 主翻译文件。Language::dump() 在运行时把它复制到 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — 应用实际读取的是导出后的文件。 |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | 首个迁移。仅在插件激活时运行,删除时回滚。这是唯一一个文件名占位符不由 Twig 自身渲染的文件 — Plugin::init() 通过单独的 str_replace 处理重命名。 |
真实上线的插件会超出此最小面。代码库内的规范参考是 storage/app/plugins/Aurius/ — 八个 Eloquent 模型、十四个迁移、十八种语言、六十多个视图、一个管理员侧栏分组、一个聊天框 UI 气泡,以及它自己的队列绑定作业。Hello World 骨架刻意做到最小,让您可以一次替换一块,而不必一次性学会每个子系统。额外控制器放在 src/Controllers/,额外模型放在 src/Models/,额外服务放在 src/Services/,额外迁移放在 database/migrations/。
Plugin::register() 在幕后做了什么
输出行说已创建并已加载,这是精确的。在复制文件与打印成功消息之间,Plugin::init() 调用 Plugin::register($name),执行五个独立步骤:
- 读取插件的
composer.json。name 字段必须与目录完全一致(acmecorp/loyalty) — 不一致会抛出 composer name in composer.json is expected to be … 异常。
- 在
plugins 数据库表中创建或更新行。title、description 与 version 取自 composer 元数据。状态被设为 inactive。
- 写入主文件。
storage/app/plugins/index.json 是启动时注册表 — AppServiceProvider::boot() 在每次请求都读取该文件来决定加载哪些插件,无需访问数据库。激活与禁用之后也会修改同一个文件。
- 立刻加载 ServiceProvider。插件的
boot() 在当前进程中运行,因此它注册的任何路由 / 视图 / Hook 在下一次请求之前就已生效。
- 物化翻译文件。
Language::dump() 读取每条 add_translation_file Hook 入口,把主文件复制到 storage/app/data/plugins/...,并以运行 vendor:publish --tag=plugin --force 收尾,使任何内置资源落到 public/plugins/...。
值得记住的心智模型:"已安装" 即 "已加载"。激活纯粹是一个状态翻转,加上插件作者绑定到激活事件上的任何动作。不存在另一个由激活触发的注册路由步骤 — 路由在 plugin:init 完成的那一刻就已注册。
未激活的插件仍被加载。Plugin::autoloadWithoutDbQuery() 当前的实现会加载 index.json 中列出的每个插件,不管其状态如何。如果某个功能必须在管理员禁用插件时真正消失,插件作者就必须显式守卫 — 一个检查 Plugin::getByName($name)->isActive() 并在失败时 abort 404 的路由中间件是常见模式。核心平台自带的 admin-console 插件就是规范示例。
激活插件
在插件已搭建并处于未激活状态后,下一步是将其标记为激活,以便其 activate_plugin_{vendor}/{name} 监听器运行迁移。两条路径:
从管理员 UI
以管理员身份登录,打开 /rui/admin/plugins,找到 Loyalty 条目,点击 Activate。页面会渲染您 routes.php 提供的图标(占位符在插件根附带一个 icon.svg — 替换为您自己的以为该条目打上品牌)。
以编程方式(测试或种子)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
两条路径都会触发 Hook::fire('activate_plugin_acmecorp/loyalty')。骨架的 ServiceProvider 在 boot() 中为该事件注册了一个 Hook::on(...) 监听器 — 该监听器调用 Artisan::call('migrate', ['--path' => ..., '--force' => true]),从而创建 acmecorp_loyalty_settings 表。
在浏览器中访问 /plugins/acmecorp/loyalty,内置的 Hello World 页面就会渲染。@{{ trans('loyalty::messages.intro') }} 引用块从 storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php 下导出的翻译文件中读取。
您的首次编辑
骨架刻意做到最小,让您可以一次替换一块,而不必一次学会每个子系统。一个合理的顺序:
- 更新
composer.json。设置真实的 title、description 与 version。管理员的 Plugins 页会渲染这些字段。
- 添加真实的迁移。在
database/migrations/ 下放一个时间戳大于已有迁移的新文件。它会在下一次激活时(或一次禁用-重新激活后)运行。
- 添加真实的模型。骨架附带
Setting 作为占位符。在 src/Models/ 下添加您自己的模型;命名空间形如 {Vendor_class}\{Name_class}\Models\YourModel。类名从小写的 vendor/name 自动派生 — acmecorp 变成 Acmecorp,loyalty 变成 Loyalty。
- 替换
DashboardController。添加您的功能真正需要的控制器。保持其精简 — 把业务逻辑下沉到 src/Services/ 类中。
- 替换视图。内置的
index.blade.php 使用了来自 CDN 的 Bootstrap 5。大多数插件作者会去掉它,转而继承宿主应用的布局。
- 在
ServiceProvider::boot() 中添加 Hook。查看 Hook 系统深入 了解四种模式。骨架已演示 EVENT(Hook::on)与 BEHAVIOR(Hook::set) — REGISTRY 与 FILTER 是接下来要学的两个。
七个首日错误及修复方法
几乎每个新插件作者的反馈都落入这七类之一。每一类都根植于 App\Model\Plugin 或 App\Providers\AppServiceProvider 中实际发布的代码,因此症状是可预测的。
1. 命名违反校验器
plugin:init 抛出 Plugin name must be in the "author/name" format 或 Author name "..." is invalid. Only lowercase letters and digits are allowed。原因:正则 ^[a-z0-9]+\/[a-z0-9]+$ 加上两侧 min:2 max:32 拒绝下划线、连字符、大写字母或短于两个字符的两侧。
修复:仅使用小写字母与数字 — 例如 acmecorp/loyalty,而不是 acme_corp/loyalty-points。
2. composer.json 名称与文件夹不匹配
搭建后,Plugin::register() 会校验渲染后的 composer.json 中的 name 与 storage/app/plugins/ 下的文件夹匹配。把 JSON 改成不同的 vendor 或 name 而不重命名目录,会抛出 Plugin name in composer.json is expected to be '{folder}', found '{json}'。
修复:同步重命名目录与 JSON,或者用新名字重新运行 plugin:init。
3. autoload.psr-4 缺失或格式不对
当 autoload 块被移除或拼写错误时,loadPluginByName() 抛出 Cannot boot plugin '{name}'. No 'autoload' found in composer.json(或对应的 'autoload.psr4' 变体)。运行时需要该映射来注册命名空间;缺失它,src/ 下任何东西都无法实例化。
修复:保留搭建生成的 autoload.psr-4 条目。它声明的命名空间前缀(Acmecorp\Loyalty\\)必须与 src/ 下每个 PHP 文件顶部的 namespace 声明一致。
4. 命名空间声明与 composer.json 不一致
PHP 的自动加载器通过剥去 composer.json 中声明的 Acmecorp\Loyalty\\ 前缀,把 Acmecorp\Loyalty\Controllers\DashboardController 解析到 src/Controllers/DashboardController.php。如果文件声明 namespace AcmeCorp\Loyalty\Controllers(AcmeCorp 中的大写 C),自动加载器就找不到它。症状:在首个请求上即出现 Class "Acmecorp\Loyalty\Controllers\DashboardController" not found。
修复:src/ 下每个 PHP 文件的 namespace 声明必须使用从小写 vendor/name 派生的精确大小写。对于 acmecorp/loyalty,那就是 Acmecorp\Loyalty。Plugin::makeClassNameFromString() 仅应用 ucfirst — 没有智能化的大小写。
5. 翻译 Hook 注册在 boot() 而非 register() 中
AppServiceProvider::boot() 在自己的 boot 阶段调用 Hook::collect('add_translation_file')。当某个插件的 boot() 运行时,该循环已经结束 — 在那里添加翻译条目意味着它永远不会被采用,trans('loyalty::messages.intro') 返回字面键。
修复:正如骨架所做的那样在 register() 中注册翻译。activate_plugin_* 与 delete_plugin_* 的生命周期 Hook 仍属于 boot()。
6. 在 boot() 中调用 $this->loadTranslationsFrom(...)
一个常见冲动是除了 Hook 之外还直接调用 Laravel 的 loadTranslationsFrom()。因为插件的 boot() 在 AppServiceProvider::boot 之后运行,第二次调用会覆盖原本指向已导出运行时文件(storage/app/data/plugins/...)的命名空间提示,把它重新指向主文件(storage/app/plugins/.../resources/lang/...)。可见症状是管理员在 Languages UI 中的编辑在运行时不再生效 — 导出克隆变成僵尸文件。
修复:仅使用 add_translation_file Hook,不要再调用 loadTranslationsFrom()。
7. register() 中注册的 Hook 依赖其他插件或内核
register() 在所有其他 provider 的 register() 完成之前运行,远早于任何 boot()。需要数据库、其他插件的服务,或者在另一个 provider 的 register() 中装配的任何单例的代码,可能因 Class not found 或 Target class does not exist 而失败。唯一属于 register() 的 Hook 是 add_translation_file(因为它必须在 AppServiceProvider::boot 的 collect 循环之前运行)。
修复:把其他每个 Hook 都放进 boot()。如果您绝对需要让某件事提前运行,请先用 app()->runningInConsole() 或 isInitiated() 守卫。
分步检查清单
端到端发布一个可工作插件的完整序列:
php artisan plugin:init {vendor}/{name} — 搭建。
- 编辑
composer.json — 设置真实的 title、description、version。
- 在
database/migrations/ 下编写您的迁移。
- 在
src/Models/ 下添加模型。
- 在
src/Controllers/ 下添加控制器。
- 在
resources/views/ 下添加视图。
- 在
routes.php 中声明路由。
- 在
ServiceProvider::boot() 中把一切串起来 — 视图、路由、Hook、资源发布。
- 登录管理员 → Plugins → Activate。迁移自动运行。
出现问题时,两个调试入口几乎覆盖所有情况。storage/logs/laravel.log 捕获启动期间抛出的任何异常,包括在 loadPluginByName() 中注册 autoload 时抛出的异常。storage/app/plugins/index.json 中每一行的 error 字段显示该插件最近一次启动失败 — 这是管理员 Plugins 页用来呈现红色错误标的依据;通过重新激活插件(或先删除再重新安装)清理该文件即可重置错误状态。
下一步去哪里
您已有脚手架、生命周期与门控大多数首日调试的七个错误。接下来两页给您剩余文档所假设的心智模型:
- 插件架构 — 启动时加载流程、为什么未激活插件仍被自动加载、主文件机制,以及
register() 与 boot() 在运行时层面的差异。
- Hook 系统 — 四种模式(REGISTRY、EVENT、BEHAVIOR、FILTER)、何时使用哪一种,以及让 BEHAVIOR 在冲突时抛错而不是悄悄覆盖的冲突语义。
当您准备好发布一个真正的功能插件时,可参考实战示例 发送驱动(Postal MTA 端到端)与 支付网关(以 Paddle 作为区域性网关)。对于 UI 工作,UI 注入 涵盖让插件挂载聊天框气泡或设置面板而不 fork 单个 Blade 的布局/侧栏/页面槽 Hook。