四种模式。一个文件。整个可扩展性表面。

插件参与核心的每种方式都流经 App\Library\HookManager。该类约 160 行,零依赖,恰好暴露四对注册/执行:add/collect(REGISTRY)、on/fire(EVENT)、set/perform(BEHAVIOR),以及 modify/filter(FILTER)。本页覆盖每种模式提供的保证、何时使用、两个插件之间的冲突长什么样,以及看起来对但在生产中失败的反模式。每个示例都根植于宿主应用中发布的调用点。

四模式地图

代码库中的每个 Hook 恰好落入四种形态之一。形态决定冲突语义、返回值处理,以及核心与插件之间什么样的交互合理。选错模式后来会以奇怪的边缘场景显现 — 提前知道哪个是哪个能避免重写集成。

模式注册执行返回冲突
REGISTRYHook::add($name, $cb)Hook::collect($name)每个回调返回值的数组合并 — 每个回调都运行,每个结果都保留
EVENTHook::on($name, $cb)Hook::fire($name, [...args])无 — 返回值被丢弃全触发 — 每个监听器都运行,副作用叠加
BEHAVIORHook::set($name, $cb)setIfEmptyHook::perform($name, [...args])已注册的单个回调返回的任何值独占 — 对同一名称的第二次 set 立即抛错
FILTERHook::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_driverapp/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107驱动类元数据 — typedriver(FQCN)、标签
register_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:120"添加服务器"选择表单元数据 — 图标、名称、描述、create_url
register_vendor_config_keysapp/Model/SendingServer.php:206每驱动配置表单字段名 — ['my_api_key', 'my_region']
add_translation_fileapp/Model/Language.php:532 + AppServiceProvider::boot()翻译文件描述符 — 语言文件夹、前缀、主文件
captcha_methodapp/Model/Setting.php:290Captcha 提供商元数据 — id、标签、渲染闭包
list_import_notificationsapp/Http/Controllers/SubscriberController.php:402名单导入对话框上方显示的 HTML 横幅片段
generate_big_notice_for_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:235发送服务器详情页的 HTML 横幅
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.php@yield('head') 之前注入的 CSS / JS 字符串
layout.body.before_closeSame files, before </body>浮动小部件 HTML — 聊天框气泡、模态框、sparkle 弹层
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.php插件贡献的管理员侧栏区段

何时使用 REGISTRY

  • 多个插件可能贡献(发送驱动、Captcha 方法、侧栏项)。
  • 宿主想要每个贡献,而不仅仅是最后一个。
  • 贡献之间不冲突地组合 — 列表项、菜单条目、横幅片段、配置元数据。

命名约定

在代码库中,REGISTRY 名称往往读作描述贡献的动词短语:register_sending_server_driveradd_translation_filecaptcha_methodgenerate_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_addedapp/Model/Customer.php:1410 + app/Http/Controllers/UserController.php:69[$customer]
user_addedapp/Model/User.php:812[$user]
new_subscriptionapp/Library/OrderFulfillment/Handlers/SubscriptionNewHandler.php:41[$subscription]
plan_changedapp/Services/Subscription/SubscriptionManagementService.php:531[$customer, $oldPlan, $newPlan]
subscription_cancelledapp/Services/Subscription/SubscriptionManagementService.php:212[$subscription]
subscription_terminated通过 AppServiceProvider 连接[$subscription]
after_verify_dkim_against_aws_sesapp/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() 独占地声明该名称 — 如果第二个调用者尝试用同一名称 setHookManager::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_jobapp/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。

如何选择模式

决策通常归结为四个问题。按顺序回答即可挑出正确形态:

  1. 多个插件是否应组合?是则 REGISTRY(独立贡献)或 FILTER(链式变换)。否则 BEHAVIOR(独占覆盖)。
  2. 宿主是否需要返回值?否则 EVENT(仅副作用)。是则 REGISTRY / BEHAVIOR / FILTER。
  3. 值是否经多手传递?是则 FILTER(链)。如每只手独立贡献,则 REGISTRY(收集)。
  4. 两个插件之间的冲突是否是 bug?是则 BEHAVIOR(启动时高调失败)。否则选无冲突的模式。

拿不准时,挑较松的模式。REGISTRY 加上一个"带外"EVENT 用于清理,几乎总比 BEHAVIOR 更灵活 — 实践中真正干掉 BEHAVIOR 的是冲突语义,而松散模式让插件无需协调即可组合。

下一步去哪里

您现在已经深入掌握四种模式以及让每种形态可预测的冲突语义。三页把这一知识变成真正插件每天会接触的表面: