这里的插件是什么
插件是一个自包含的 Laravel 包,位于宿主 AcelleMail 安装中的 storage/app/plugins/{vendor}/{name}/。它携带自己的 composer.json、自己的 PSR-4 命名空间、自己的 ServiceProvider、自己的路由、视图、迁移与翻译。结构上就像一个小型 Laravel 应用 — 除了一个决定性的区别。
宿主应用不通过根 Composer 自动加载器安装插件。没有 composer require 步骤,没有 vendor/{vendor}/{name}/ 目录,也没有 composer.lock 中的条目。相反,每次应用启动时,它自行完成下列工作:
- 读取每个插件自己的
composer.json。
- 用一个全新的
Composer\Autoload\ClassLoader 实例注册其中声明的 PSR-4 命名空间。
- 对
extra.laravel.providers 下列出的 ServiceProvider 调用 App::register(...)。
该决定是刻意为之。把插件当作 Composer 安装的包,会让宿主应用的 composer.json 变成一个移动靶 — 每次安装、停用或升级都会改动 lockfile。运行时加载器让宿主的依赖图保持稳定:插件自带元数据,宿主可以扫描、忽略或重排它们而无需触及 vendor/。
主宰整个系统的五个文件
插件生命周期中几乎每个行为都在宿主应用的五个文件中实现。读这些文件的源码是确认本文档任何内容最快的方式:
| 文件 | 职责 |
app/Console/Commands/InitPlugin.php | php artisan plugin:init 的 CLI 入口。Plugin::init($name) 的薄包装。 |
app/Model/Plugin.php | 完整生命周期:搭建、注册、加载、激活、禁用、删除,外加主文件机制。 |
app/Library/HookManager.php | 插件用来扩展核心行为的注入原语 — REGISTRY、EVENT、BEHAVIOR、FILTER。约 160 行,零依赖。 |
app/Providers/AppServiceProvider.php | 启动时插件自动加载 + 翻译注册。把插件连接到运行中应用的唯一调用点。 |
app/Model/Language.php | 把插件翻译文件物化到 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/。这种间接层让管理员可以通过 Languages UI 编辑翻译,而无需触及插件源文件。 |
这些加起来宿主侧代码远少于三千行。插件系统刻意保持小巧 — 插件的每个约束都来自这五个文件之一,再无别处可查。
启动与加载流程
每个请求、队列工作进程、调度器节拍与 Artisan 命令都走相同的启动序列。其插件相关的切片如下:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
该序列中有两个实现细节对插件作者影响极大:
1. 启动时发现从不查询数据库
要加载的插件清单来自 storage/app/plugins/index.json,而不是 plugins 数据库表。ServiceProvider 无法安全地查询数据库 — 在 AppServiceProvider::boot() 运行的时刻,连接可能尚不存在(像 artisan db:create 这样的 CLI 命令)或者 schema 尚未迁移(CI 测试初始化)。把启动时注册表放在 JSON 文件中规避了整个问题。
数据库表仍然存在。它存储与 JSON 文件相同的 status,加上像 title、description 与 version 这样的面向用户的元数据。管理员的 Plugins 页从数据库读取;启动加载器从 JSON 读取。两者由 Plugin::register()、activate() 与 disable() 保持同步 — 每次状态变更都写入两个存储。
2. autoloadWithoutDbQuery() 当前加载索引中的每个插件 — 包括未激活的
当前实现遍历 index.json 中每条记录并对其调用 loadPluginByName,无论 status 如何。原因是务实的:即使是未激活的插件,也需要注册其路由(这样当管理员点击 "deactivate" 而未立即重新加载时管理员页面仍可工作),还需要让其翻译可用(这样导出克隆不会过时)。
后果是 AcelleMail 插件系统中的"未激活"不等同于"未加载"。下一节会把这个区分讲精确。
composer.json 契约
插件的 composer.json 不只是元数据 — 它是加载器依赖的运行时契约。重要的键是:
| 键 | 用途 |
name | 规范的插件 ID。必须与 storage/app/plugins/ 下的目录完全一致。若不一致,Plugin::register() 会抛错。 |
autoload.psr-4 | 把插件的命名空间前缀映射到 src/。必填 — 缺失则 loadPluginByName() 抛错,插件无法启动。 |
extra.laravel.providers | 完全限定类名数组。加载器对每个类调用 App::register()。如果插件想注册路由、视图、Hook 或其他任何东西,则必填。 |
extra.setting-route | 管理员 Plugins 页作为该插件 "Settings" 按钮链接到的 controller@method。可选 — 没有配置的插件可省略。 |
title, description, version | 在管理员 Plugins 列表中呈现。title 必填;其余项回退到默认值。 |
autoload 映射在运行时注册,而不是在安装时。编辑插件的 PSR-4 映射后您无需运行 composer dump-autoload — 宿主在每次请求都实例化一个全新的 ClassLoader 并重新读取文件。这也是为何更改插件命名空间只需搜索替换加上一次对宿主的请求。
主文件(storage/app/plugins/index.json)
主文件是一个以插件名为键的扁平 JSON 对象。每条记录至少存储一个 status,以及最近一次启动失败时可选的 error 字符串。一个典型文件看起来像这样:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
有三个宿主侧方法负责该文件。每次状态变更都经过其中之一:
Plugin::updatePluginMasterFile($name, $params) — 合并写入单个插件的条目。把 null 作为第二个参数传入可彻底移除该条目(删除路径)。
Plugin::resetPluginMasterFile() — 通过遍历 Plugin::all() 从头重建文件。当 JSON 损坏或与数据库不同步时用于恢复。
Plugin::getErroredPluginNames() — 读取每条记录,返回 error 非空的名称。管理员 Plugins 列表用它把损坏插件推到底部并呈现红色错误标。
当 autoloadWithoutDbQuery() 在 try/catch 中包裹一次 loadPluginByName() 调用且该调用抛错时,error 键被设置。异常消息被记录下来,以便管理员 UI 不必重新触发失败就能展示。重新激活一个干净的插件会自动清除该字段。
主文件是启动时的单一真相来源。如果您需要从卡住的插件中恢复(管理员 UI 宕机、数据库离线),直接编辑 storage/app/plugins/index.json。下一次请求会读取更新后的状态并相应行动。数据库行是长期元数据;JSON 文件是运行时注册表。
register() 与 boot() 的时序
Laravel 先按注册顺序运行每个 ServiceProvider 的 register() 方法,然后才调用任何 boot()。这是 Laravel 的常识 — 但它在插件系统中有直接后果。
什么放在 register() 中
- 常量与绑定 — 它们必须在宿主自己的
boot() 运行之前存在。
add_translation_file Hook — 且仅此 Hook。宿主的 AppServiceProvider::boot() 在自己的 boot 阶段调用 Hook::collect('add_translation_file')。当某个插件的 boot() 运行时,该循环已经结束。若插件在 boot() 中注册翻译条目,它永远不会被采用 — trans('myname::messages.intro') 返回字面键。
什么放在 boot() 中
- 路由与视图 —
$this->loadRoutesFrom(...)、$this->loadViewsFrom(...)。
- 资源发布 —
$this->publishes([...], 'plugin')。
- 生命周期事件监听器 —
Hook::on('activate_plugin_{name}', ...)、Hook::on('delete_plugin_{name}', ...)。
- 图标 URL —
Hook::set('icon_url_{vendor}/{name}', ...)。
- 其他每个 Hook — REGISTRY
add、EVENT on、BEHAVIOR set、FILTER modify。任何依赖容器绑定、配置或其他插件的东西。
不要在插件的 boot() 中调用 $this->loadTranslationsFrom(...)。宿主已经通过 add_translation_file Hook 接入命名空间,把它指向 storage/app/data/plugins/... 下的导出运行时文件。在插件的 boot() 中再次 loadTranslationsFrom 会覆盖宿主的提示,把命名空间重新指向 resources/lang/... 下的主文件。可见症状是管理员在 Languages UI 中的编辑在运行时不再生效 — 导出克隆变成僵尸文件。仅使用 Hook。
为什么未激活的插件仍影响应用
启动时的 autoloadWithoutDbQuery() 调用会加载 index.json 中的每个插件,不管状态如何。所以一个"未激活"的插件仍然在宿主中注册了下列每一项:
- 它的路由 — 由
boot() 中的 $this->loadRoutesFrom(...) 声明。
- 它的视图 — 由
$this->loadViewsFrom(...) 声明。
- 它的中间件别名 — 由标准 Laravel API 注册。
- 它的 Hook 监听器 — 每个
Hook::add、Hook::on、Hook::modify、Hook::set 仍会触发。
- 它的 UI 片段 — 通过
layout.head.assets、layout.body.before_close、admin.sidebar.groups 或页面槽 REGISTRY Hook 贡献的任何内容仍会出现。
激活实际上仅添加插件作者绑定到 activate_plugin_{vendor}/{name} 上的任何内容。骨架的监听器运行迁移。不存在隐式的"激活时注册路由"或"未激活时移除路由"步骤 — 路由在应用启动那一刻就已注册。
如果某个功能必须在管理员禁用插件时真正消失,插件作者就必须显式守卫。常见模式在 storage/app/plugins/acelle/console 中:路由始终加载,但一个名为 console.active 的路由中间件在 Plugin::getByName('acelle/console')->isActive() 返回 false 时 abort 404。当"已停用"应意味着"不可访问"时,照搬该模式。
同样适用于 UI Hook。如果通过 layout.body.before_close 注入的聊天框气泡在插件未激活时应当隐藏,闭包体必须先检查 Plugin::enabled('myvendor/myplugin'),并在 false 时返回 null。宿主在渲染前会自动过滤掉假值返回。
生命周期:register / activate / disable / delete
四种状态,四个宿主侧方法。每个都对自己做什么、不做什么把握得很精确。
Register / 安装
Plugin::register($name) 是入口 — 在 plugin:init 结束时以及每次通过管理员 UI 成功上传时被自动调用。五个步骤是:
- 读取
composer.json,把 title / description / version 复制到模型。
- 在
plugins 表中插入或更新行,status = inactive。
- 写入
storage/app/plugins/index.json,内容为 { "name": { "status": "inactive" } }。
- 调用
Plugin::load($withServiceProvider = true) — 注册 PSR-4 前缀并立即启动 ServiceProvider,使任何路由 / 视图 / Hook 在当前进程中即时生效。
- 调用
Language::dump() 物化翻译文件,然后运行 vendor:publish --tag=plugin --force 把任何内置资源复制到 public/plugins/...。
register 之后插件就已安装并已加载。唯一缺少的是插件选择绑定到其 activate 事件上的任何动作 — 通常是一次迁移运行。
Activate
$plugin->activate() 由管理员 UI 的 "Activate" 按钮调用(以及由直接调用模型的测试 / 种子代码)。它按顺序做四件事:
- 触发
Hook::fire('activate_plugin_'.$name)。骨架的监听器对 storage/app/plugins/{vendor}/{name}/database/migrations 运行 artisan migrate。其他插件可以注册额外监听器 — REGISTRY 行为,每个监听器都会触发。
- 对照宿主必填键清单(
name、version、app_version)重新校验插件的 composer.json。
- 把数据库
status 设为 active。
- 更新主文件:
{ "status": "active", "error": null } — 清除任何此前的启动错误。
Disable
$plugin->disable() 仅:
- 把数据库
status 设为 inactive。
- 用新状态更新主文件并清除任何记录的
error。
它不会卸载路由、视图、ServiceProvider、Hook 监听器或在启动时注册的任何其他内容。宿主没有"反注册一个 ServiceProvider"的概念 — Laravel 本身就不支持。Disable 是状态翻转,不是卸载。
Delete
$plugin->deleteAndCleanup($keepData = false) 走完整的拆卸:
- 触发
Hook::fire('delete_plugin_'.$name, [$keepData])。骨架的监听器运行 migrate:rollback;$keepData = true 可对拥有管理员希望保留之数据的插件跳过该步骤。
- 递归删除
storage/app/plugins/... 下的插件目录。
- 从
plugins 数据库表删除行。
- 从主文件移除条目。
直到下一次请求启动一个新进程之前,插件的 ServiceProvider 仍加载在内存中。下一次请求读取(现已变小的)主文件,不再加载该插件,进程内状态随请求生命周期被丢弃。
两层注入
插件通过两个并行层影响宿主应用。区分两者是让文档其余部分能够干净地映射到代码的关键。
第 1 层 — Laravel 注册
通过 ServiceProvider,插件使用标准 Laravel 容器 API 扩展应用:
$this->loadRoutesFrom(__DIR__ . '/../routes.php') — 增加插件的 HTTP 表面。
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — 在 myname::view 命名空间下暴露 Blade 视图。
$this->publishes([...], 'plugin') — 安装时把内置资源复制到宿主的 public/plugins/{vendor}/{name}/。
- 中间件别名、容器绑定、控制台命令、定时任务、队列监听器 — Laravel 本身支持的一切。
第 2 层 — 基于 Hook 的注入
宿主在精心选择的扩展点上调入 App\Library\HookManager 原语。插件向这些点注册监听器以参与。模式恰好有四种:REGISTRY、EVENT、BEHAVIOR、FILTER。下一篇深入 — Hook 系统 — 完整覆盖每一种。
现在要知道两件事:(1) 宿主触发的每个 Hook 都是稳定契约 — 一旦公布,名称与签名在版本之间不会变。(2) BEHAVIOR 是独占的 — 如果两个插件尝试用相同名称 Hook::set,第二次调用会立即抛错。没有静默覆盖;冲突在启动时浮现,而不是在生产中。
代码库附带三个布局级 REGISTRY Hook,几乎每个扩展 UI 的插件都会用到:
| Hook 键 | 触发位置 | 用途 |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php,在 @yield('head') 之前 | 必须在页面内容之前加载的 CSS / JS(聊天框样式、sparkle 弹层脚本) |
layout.body.before_close | 同一布局,正好在 </body> 之前 | 浮动小部件 — 聊天框气泡、模态框、sparkle 弹层 |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | 插件贡献的管理员侧栏区段 |
三者都遵循相同惯用法:每个回调返回渲染好的 HTML 或 null;宿主用 array_filter 迭代并用 {!! !!} 输出每个片段。返回 null 是按功能开关或插件状态来抑制贡献而不抛错的常规方式。
运行时的翻译流
插件翻译并不是直接从插件源 resources/lang/ 文件夹提供的。流程是间接的,正是这种间接让管理员可以通过宿主的 Languages UI 编辑翻译而无需提交到插件源文件。已验证的顺序:
- 插件的
register() 贡献一个 Hook::add('add_translation_file', ...) 条目,指向 storage/app/data/plugins/{vendor}/{name}/lang/。
- 宿主的
AppServiceProvider::boot() 收集所有此类条目,并对每个调用 $this->loadTranslationsFrom()。
- 每次
Plugin::register(),宿主都调用 Language::dump()。
Language::dump() 读取插件位于 resources/lang/en/messages.php 的主文件,并为每种受支持的语言把它复制到 storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php。
- Languages 管理员 UI 编辑的是导出后的运行时文件。插件源主文件保持不变。
需要记住的两条路径:
- 主文件(您在源码中编辑这个):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- 运行时文件(自动生成,应用实际读取的):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
当您编辑主文件时,运行 php artisan translation:upgrade 把主文件重新同步到所有语言运行时文件(保留管理员通过 Languages UI 编辑的任何翻译)。完整机制 — 主 vs 运行时、升级语义、按语言回退 — 在 翻译 中有专门深入。
这对插件作者意味着什么
从上述架构落下五条规则。把它们内化,可以把后续文档中大多数表面复杂度变成对该清单的核对。
- 把
boot() 当作注册阶段。路由、视图、Hook、生命周期监听器 — 几乎一切都放这里。唯一放在 register() 中的是 add_translation_file Hook(因为宿主在任何插件的 boot() 运行之前就收集它)。
- 未激活不等于未加载。您在启动时注册的任何东西都生效,与
active / inactive 状态无关。如果某个功能必须在禁用时真正消失,请用路由中间件或在 Hook 闭包内的 Plugin::enabled(...) 检查来显式守卫。
- 通过主文件编辑翻译,永远不要直接通过
loadTranslationsFrom()。运行时读取的是 storage/app/data/plugins/... 下的导出克隆。自己把命名空间指向主目录会覆盖宿主的提示,破坏 Languages UI。
- 让
composer.json 保持精简稳定。运行时加载器每次请求都读它。autoload.psr-4、extra.laravel.providers、name、title 是宿主实际使用的键。添加额外键没问题但毫无作用。
- 四种 Hook 模式是唯一契约。当您发现自己想"导入"一个核心类去扩展它时 — 请暂停。插件契约是单向的:核心声明 Hook,插件做出反应。如果您需要的扩展点尚不存在为 Hook,正确做法是对宿主提 issue,而不是从您插件的控制器里
use Acelle\Model\Customer。
下一步去哪里
您已掌握架构。两页把这一心智模型变成您日常会用到的 API:
- Hook 系统 — 深入四种模式,配以从核心 grep 来的真实调用点。冲突语义、何时使用哪种模式,以及看起来对但在生产中失败的反模式。
- UI 注入 — 上文的布局级 Hook,加上让插件向已有页面注入卡片而不 fork 单个 Blade 的
page.{controller}.{action}.{slot} 契约。
当您准备好发布一个真正的功能插件时,实战示例是 发送驱动(Postal MTA 端到端)与 支付网关(以 Paddle 作为区域性网关)。作为一次完整的阅读理解练习,Aurius 案例 端到端走完规范级复杂插件:八个模型、十四个迁移、十八种语言,以及生产中用到的每个 Hook 表面。