四模式地图
代码库中的每个 Hook 恰好落入四种形态之一。形态决定冲突语义、返回值处理,以及核心与插件之间什么样的交互合理。选错模式后来会以奇怪的边缘场景显现 — 提前知道哪个是哪个能避免重写集成。
| 模式 | 注册 | 执行 | 返回 | 冲突 |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | 每个回调返回值的数组 | 合并 — 每个回调都运行,每个结果都保留 |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | 无 — 返回值被丢弃 | 全触发 — 每个监听器都运行,副作用叠加 |
| BEHAVIOR | Hook::set($name, $cb) 或 setIfEmpty | Hook::perform($name, [...args]) | 已注册的单个回调返回的任何值 | 独占 — 对同一名称的第二次 set 立即抛错 |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | 按注册顺序逐个回调变换后的值 | 链式 — 每个回调接收前一个的输出 |
一个有用的心智模型:REGISTRY 回答"您想贡献什么";EVENT 回答"您想知道吗";BEHAVIOR 回答"我应该如何做";FILTER 回答"这个值应该变成什么"。接下来四节深入每一种,配以核心中发布的真实调用点。
REGISTRY — add() + collect()
插件向一个命名列表贡献一个或多个项。宿主调用 collect() 拿到所有贡献的数组。每个回调都运行,每个返回值都按注册顺序被捕获。
机制
// Plugin (in ServiceProvider::boot())
Hook::add('register_sending_server_driver', fn() => [
'type' => 'postal',
'driver' => '\\AcmeCorp\\Postal\\Driver',
'name' => 'Postal MTA',
]);
// Core (app/Model/SendingServer.php:191)
foreach (Hook::collect('register_sending_server_driver') as $meta) {
$drivers[$meta['type']] = $meta['driver'];
}
核心中真实的 REGISTRY Hook
| Hook 键 | 宿主在何处 collect | 插件贡献什么 |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | 驱动类元数据 — type、driver(FQCN)、标签 |
register_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:120 | "添加服务器"选择表单元数据 — 图标、名称、描述、create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | 每驱动配置表单字段名 — ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | 翻译文件描述符 — 语言文件夹、前缀、主文件 |
captcha_method | app/Model/Setting.php:290 | Captcha 提供商元数据 — id、标签、渲染闭包 |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | 名单导入对话框上方显示的 HTML 横幅片段 |
generate_big_notice_for_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:235 | 发送服务器详情页的 HTML 横幅 |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | 在 @yield('head') 之前注入的 CSS / JS 字符串 |
layout.body.before_close | Same files, before </body> | 浮动小部件 HTML — 聊天框气泡、模态框、sparkle 弹层 |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | 插件贡献的管理员侧栏区段 |
何时使用 REGISTRY
- 多个插件可能贡献(发送驱动、Captcha 方法、侧栏项)。
- 宿主想要每个贡献,而不仅仅是最后一个。
- 贡献之间不冲突地组合 — 列表项、菜单条目、横幅片段、配置元数据。
命名约定
在代码库中,REGISTRY 名称往往读作描述贡献的动词短语:register_sending_server_driver、add_translation_file、captcha_method、generate_big_notice_for_sending_server。带单数动词的名称(register_*、add_*)暗示"贡献这种东西一个",但 collect() 机制仍会聚合每个贡献 — 注册侧没有任何东西把插件限制为单个条目。
EVENT — on() + fire()
当宿主中发生某事时,插件做出反应。返回值被丢弃 — 契约是单向的:核心通知,插件叠加副作用。每个监听器都按注册顺序运行。
机制
// Plugin (in ServiceProvider::boot())
Hook::on('customer_added', function ($customer) {
LoyaltyPoints::award($customer, 100, 'welcome_bonus');
});
// Core (app/Model/Customer.php:1410)
Hook::fire('customer_added', [$customer]);
核心触发的真实 EVENT Hook
| Hook 名称 | 触发位置 | 参数袋 |
customer_added | app/Model/Customer.php:1410 + app/Http/Controllers/UserController.php:69 | [$customer] |
user_added | app/Model/User.php:812 | [$user] |
new_subscription | app/Library/OrderFulfillment/Handlers/SubscriptionNewHandler.php:41 | [$subscription] |
plan_changed | app/Services/Subscription/SubscriptionManagementService.php:531 | [$customer, $oldPlan, $newPlan] |
subscription_cancelled | app/Services/Subscription/SubscriptionManagementService.php:212 | [$subscription] |
subscription_terminated | 通过 AppServiceProvider 连接 | [$subscription] |
after_verify_dkim_against_aws_ses | app/SendingServers/Drivers/Vendors/Amazon/AmazonBaseDriver.php:447 | [$domain, $tokens] |
activate_plugin_{vendor}/{name} | app/Model/Plugin.php:487 | (无参数) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData] — 默认为 false |
何时使用 EVENT
- 插件想做出反应,但不需要影响核心的结果。
- 仅副作用 — 发送 webhook、写日志、发放积分、派发队列作业。
- 事件触发时核心已经承诺该动作;监听器无法取消它。
EVENT 与 $keepData 标志。delete_plugin_* 事件是参数袋承载带外信号的唯一一处。$keepData = true 告诉监听器"管理员希望保留该插件的数据,跳过 migrate:rollback"。骨架监听器已经尊重它:function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }。没有可保留数据的插件可凭默认值忽略该参数。
BEHAVIOR — set() + perform()
一个可调用项独占命名行为。宿主调用 perform() 执行当前注册者。set() 独占地声明该名称 — 如果第二个调用者尝试用同一名称 set,HookManager::set() 立即抛出异常。没有静默覆盖;冲突在启动时浮现,而不是在生产中。
机制
// Plugin overrides (in ServiceProvider::boot())
Hook::set('dispatch_list_import_job', fn($list, $file) =>
new \\AcmeCorp\\FastImport\\FasterImportJob($list, $file));
// Core registers default + executes (app/Http/Controllers/SubscriberController.php:433)
Hook::setIfEmpty('dispatch_list_import_job', function ($list, $filepath) use ($request) {
return new ImportJob($list, $filepath, $request);
});
$currentJob = Hook::perform('dispatch_list_import_job', [$list, $filepath]);
dispatch($currentJob);
setIfEmpty 时序规则
宿主的默认走 setIfEmpty() 而非 set() — 差别是有意为之。setIfEmpty 仅在尚无人认领该名称时才注册回调;如果某个插件已在其 boot() 中调用了 set,宿主的默认会被静默跳过。
这意味着 setIfEmpty 必须紧贴 perform() 之前放置,位于控制器或模型中,不能在 ServiceProvider 中。原因:当控制器的请求处理器运行时,每个插件的 ServiceProvider::boot() 都已完成 — 因此任何通过 set 注册的插件覆盖都已生效。把默认放在 register() 或 boot() 中可能在插件的 set() 之前运行,从而把插件锁在外面。
核心中真实的 BEHAVIOR Hook
| Hook 名称 | 宿主在何处注册默认 + perform | 被覆盖的是什么 |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | 管理员导入 CSV 时入队的作业类 — 插件可换入更快、分布式或带埋点的变体 |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | 管理员 Plugins 页中插件条目渲染的图像 URL — 默认 /images/plugin.svg;插件在其 boot() 中调用 Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) |
何时使用 BEHAVIOR
- 应当只运行一段逻辑。
- 存在合理默认,但插件应能完整替换它。
- 两个插件认领同一行为是 bug 而非特性 — 您希望它响亮地失败。
BEHAVIOR 的独占性是特性而非问题。如果两个插件确实需要影响同一行为,正确形态是 REGISTRY(各贡献候选,宿主选其一)或 FILTER(每个插件通过链式变换该值)。为"共享逻辑"而选 BEHAVIOR 会迫使两位插件作者进行一场无法取胜的竞争。HookManager::set() 会在第二个插件启动的那一刻抛出 Behavior "{name}" has already been registered — 因此该冲突不可能进入生产。
FILTER — modify() + filter()
一个值穿过一连串回调。每个回调接收当前值(加上任意额外位置参数)并返回传给下一个的值。宿主用初始值调用 <code>filter()</code>;返回是每个插件依次出手后的最终值。
机制
// Plugin (in ServiceProvider::boot())
Hook::modify('sidebar-menu-items', function (array $items) {
array_splice($items, 1, 0, [
['label' => 'Loyalty', 'url' => route('loyalty_points.dashboard'), 'icon' => 'star'],
]);
return $items;
});
// Core
$items = Hook::filter('sidebar-menu-items', $defaultItems);
可选位置参数
Hook::filter($name, $value, $extraParams) 接受第三个数组,作为位置参数与当前值一起传给每个回调。插件指南中的契约示例是邮件名单重定向:
// Plugin
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
if (MyPlugin::shouldRedirect($list, $request->user())) {
return redirect()->route('my_plugin.custom_page', $list->uid);
}
return $redirect; // null means "do not redirect"
});
// Core
$redirect = Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
return $redirect;
}
// continue rendering
何时使用 FILTER
- 值在被宿主使用之前穿过若干插件。
- 每个插件都能与前一个组合 — 向菜单中添加、修改邮件内容、调节重定向、变换载荷。
- 返回未变的输入是常规的退出方式 — 无异常,无特殊信号。
FILTER 是当前核心代码库中使用最少的模式。HookManager 的实现稳定(该文件第 143-159 行),契约已为插件作者写明,但核心在生产热路径上除已记载的 page.maillist.show.redirect 示例外尚未调用 Hook::filter。希望支持可组合变换的新宿主侧热路径应优先选择 FILTER 而非 BEHAVIOR — 链式语义正是大多数"我希望插件能向这里添加"场景所需要的。
四种模式的冲突语义
当两个插件指向同一个 Hook 名称时,四种模式反应不同。知道您拥有哪种模式,就能确切知道冲突长什么样,以及它会在启动时浮现还是在更晚的某刻。
| 模式 | 两个插件指向同一名称时的行为 | 如何浮现 |
| REGISTRY | 两个贡献都保留;collect 返回两者 | 无冲突 — 按设计。顺序即注册顺序。 |
| EVENT | 两个监听器都运行;副作用叠加 | 无冲突 — 按设计。顺序即注册顺序。 |
| BEHAVIOR | 第二次 set 调用抛出 Behavior "{name}" has already been registered | 启动时异常 — 插件的 ServiceProvider::boot() 失败,主文件记录错误,管理员 Plugins 页呈现红色标记。生产从不会遇到静默覆盖。 |
| FILTER | 值按注册顺序穿过两个回调 | 无冲突 — 按设计。每个回调可通过返回未变输入来退出。 |
四种模式中三种因聚合而无冲突。BEHAVIOR 是唯一具有独占所有权的,其失败模式是启动时的硬异常 — 这是有意的设计选择,使误用不可能与可工作的安装共存。
参数袋约定
每个 fire / collect / perform / filter 都采用相同形态:$name, $value(或默认值), $params。$params 数组按位置展开进入已注册的回调。链中每个回调都接收相同参数。
- 保持参数袋小而稳定。一旦 Hook 发布,参数就成了契约 — 添加位置参数会破坏每个已经以固定签名绑定闭包的插件。
- 传递模型,而不是字段包。
fire('customer_added', [$customer]) 让监听器决定读取哪些字段;fire('customer_added', [$customer->email, $customer->uid, ...]) 会把参数锁定到 Hook 发布时存在的那些字段。
- 用额外 Hook 而非重载参数。如果某个槽位需要更丰富的上下文,注册另一个 Hook 键而不是再加第五个可选位置参数。
按引用 collect 的怪点
核心中的一小片代码把 collect() 与按引用参数一起用作变更 Hook — 超出规范的四模式模型。filter_aws_ses_dns_records 调用点是规范示例:
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
宿主触发该 Hook,期望插件就地修改 $dkims 与 $spf。严格说这是对 REGISTRY 的误用 — 真正的 FILTER 链才是正确形态。该行为之所以工作,是因为 PHP 闭包遵循按引用参数,但插件作者不应以此风格写新的调用点。请改用 FILTER(单值变换)或 REGISTRY(多个不可变贡献)。
六种应避免的反模式
下列模式乍看都很对,却以微妙的方式破裂。每一个都根植于生产插件中已见过的一类 bug。
1. 在需要 boot() 的地方把 Hook 注册到 register()
宿主的 register() 在任何 ServiceProvider 的 boot() 之前运行。在那里注册的 Hook 会先于其他 provider 的 register() 装配的依赖触发。症状:在首个请求即出现 Class not found,早于任何插件代码执行其主路径。修复:只有 add_translation_file Hook 属于 register();其他一切放进 boot()。
2. 在目标是"共享"时却伸手去拿 BEHAVIOR
BEHAVIOR 在第二次 set 时抛错。如果两个插件确实需要影响同一点,FILTER(组合)或 REGISTRY(收集)才是正确形态。修复:重写 Hook 契约 — 改为输出链或列表,而不是独占可调用项。
3. 把 setIfEmpty 放在 ServiceProvider 中
setIfEmpty 仅在尚无人注册时才生效。放在 register() 或 boot() 中意味着它可能在某个插件的 set() 之前运行,把该插件锁在外面。修复:把 setIfEmpty 直接放在对应 perform 调用之上,位于控制器或模型中,使每个插件的 boot() 都已完成。
4. 在 REGISTRY 回调中改写共享状态
collect 按注册顺序调用每个回调。把写入共享缓存或会话作为副作用的回调会让 Hook 非确定 — 以不同的插件顺序运行两次会得到不同的缓存状态。修复:保持 add 回调纯净。如果贡献依赖副作用,单独注册一个 EVENT 监听器。
5. 给已有 Hook 添加位置参数
一旦 Hook 投入生产,插件已经以原有 arity 绑定了闭包。添加第五个位置参数会破坏所有省略它的绑定。修复:注册新的 Hook 名(customer_added_v2)并接受一个过渡窗口,或通过已在参数袋中的模型对象传递新上下文。
6. 用 collect() 求单一答案
REGISTRY 返回数组 — 即便只有一个插件注册,宿主拿到的是 [$result]。把它当作答案($first = Hook::collect(...)[0])会静默挑选先启动的那个插件,没有任何冲突语义。修复:如果恰好期望一个答案,请使用 BEHAVIOR。
如何选择模式
决策通常归结为四个问题。按顺序回答即可挑出正确形态:
- 多个插件是否应组合?是则 REGISTRY(独立贡献)或 FILTER(链式变换)。否则 BEHAVIOR(独占覆盖)。
- 宿主是否需要返回值?否则 EVENT(仅副作用)。是则 REGISTRY / BEHAVIOR / FILTER。
- 值是否经多手传递?是则 FILTER(链)。如每只手独立贡献,则 REGISTRY(收集)。
- 两个插件之间的冲突是否是 bug?是则 BEHAVIOR(启动时高调失败)。否则选无冲突的模式。
拿不准时,挑较松的模式。REGISTRY 加上一个"带外"EVENT 用于清理,几乎总比 BEHAVIOR 更灵活 — 实践中真正干掉 BEHAVIOR 的是冲突语义,而松散模式让插件无需协调即可组合。
下一步去哪里
您现在已经深入掌握四种模式以及让每种形态可预测的冲突语义。三页把这一知识变成真正插件每天会接触的表面: