从空白终端到通过测试的 Hello World 插件 — 大约五分钟。

一条 Artisan 命令即可在 storage/app/plugins/{vendor}/{name}/ 下搭建一个自包含的 Laravel 包。当您读完成功消息时,数据库行已插入,主文件已更新,ServiceProvider 已在运行中的应用里启动 — 即便插件仍处于未激活状态。本页端到端走完整个流程,并讲解几乎覆盖新作者全部失败场景的七个首日错误。

前置条件

本代码库中的插件是一个小型 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} 的身份 — 例如 Auriusaix/sampleathena/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运行时契约:nameautoload.psr-4extra.laravel.providers 都是必填。缺少它们,加载器无法注册命名空间或启动 provider。
src/ServiceProvider.phpLaravel 唯一看见的入口点。在 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.phpDashboardController 渲染的 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),执行五个独立步骤:

  1. 读取插件的 composer.jsonname 字段必须与目录完全一致(acmecorp/loyalty) — 不一致会抛出 composer name in composer.json is expected to be … 异常。
  2. plugins 数据库表中创建或更新行。titledescriptionversion 取自 composer 元数据。状态被设为 inactive
  3. 写入主文件。storage/app/plugins/index.json 是启动时注册表 — AppServiceProvider::boot() 在每次请求都读取该文件来决定加载哪些插件,无需访问数据库。激活与禁用之后也会修改同一个文件。
  4. 立刻加载 ServiceProvider。插件的 boot() 在当前进程中运行,因此它注册的任何路由 / 视图 / Hook 在下一次请求之前就已生效。
  5. 物化翻译文件。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 下导出的翻译文件中读取。

您的首次编辑

骨架刻意做到最小,让您可以一次替换一块,而不必一次学会每个子系统。一个合理的顺序:

  1. 更新 composer.json设置真实的 titledescriptionversion。管理员的 Plugins 页会渲染这些字段。
  2. 添加真实的迁移。database/migrations/ 下放一个时间戳大于已有迁移的新文件。它会在下一次激活时(或一次禁用-重新激活后)运行。
  3. 添加真实的模型。骨架附带 Setting 作为占位符。在 src/Models/ 下添加您自己的模型;命名空间形如 {Vendor_class}\{Name_class}\Models\YourModel。类名从小写的 vendor/name 自动派生 — acmecorp 变成 Acmecorployalty 变成 Loyalty
  4. 替换 DashboardController添加您的功能真正需要的控制器。保持其精简 — 把业务逻辑下沉到 src/Services/ 类中。
  5. 替换视图。内置的 index.blade.php 使用了来自 CDN 的 Bootstrap 5。大多数插件作者会去掉它,转而继承宿主应用的布局。
  6. ServiceProvider::boot() 中添加 Hook。查看 Hook 系统深入 了解四种模式。骨架已演示 EVENT(Hook::on)与 BEHAVIOR(Hook::set) — REGISTRY 与 FILTER 是接下来要学的两个。

七个首日错误及修复方法

几乎每个新插件作者的反馈都落入这七类之一。每一类都根植于 App\Model\PluginApp\Providers\AppServiceProvider 中实际发布的代码,因此症状是可预测的。

1. 命名违反校验器

plugin:init 抛出 Plugin name must be in the "author/name" formatAuthor 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 中的 namestorage/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\ControllersAcmeCorp 中的大写 C),自动加载器就找不到它。症状:在首个请求上即出现 Class "Acmecorp\Loyalty\Controllers\DashboardController" not found

修复:src/ 下每个 PHP 文件的 namespace 声明必须使用从小写 vendor/name 派生的精确大小写。对于 acmecorp/loyalty,那就是 Acmecorp\LoyaltyPlugin::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 foundTarget class does not exist 而失败。唯一属于 register() 的 Hook 是 add_translation_file(因为它必须在 AppServiceProvider::boot 的 collect 循环之前运行)。

修复:把其他每个 Hook 都放进 boot()。如果您绝对需要让某件事提前运行,请先用 app()->runningInConsole()isInitiated() 守卫。

分步检查清单

端到端发布一个可工作插件的完整序列:

  1. php artisan plugin:init {vendor}/{name} — 搭建。
  2. 编辑 composer.json — 设置真实的 titledescriptionversion
  3. database/migrations/ 下编写您的迁移。
  4. src/Models/ 下添加模型。
  5. src/Controllers/ 下添加控制器。
  6. resources/views/ 下添加视图。
  7. routes.php 中声明路由。
  8. ServiceProvider::boot() 中把一切串起来 — 视图、路由、Hook、资源发布。
  9. 登录管理员 → 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。