The four-pattern map
Every hook in the codebase falls into exactly one of four shapes. The shape determines the conflict semantics, the return-value handling, and what kind of interaction makes sense between core and plugins. Reaching for the wrong pattern shows up later as a strange edge case — knowing which is which up front saves rewriting the integration.
| Pattern | Register | Execute | Returns | Conflict |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | array of every callback's return value | Merge — every callback runs, every result stays |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | nothing — return values are discarded | All-fire — every listener runs, side effects compose |
| BEHAVIOR | Hook::set($name, $cb) or setIfEmpty | Hook::perform($name, [...args]) | whatever the single registered callback returns | Exclusive — second set on the same name throws immediately |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | the value, transformed through every callback in registration order | Chain — each callback receives the previous one's output |
A useful mental model: REGISTRY answers "what do you want to contribute"; EVENT answers "did you want to know"; BEHAVIOR answers "how should I do this"; FILTER answers "what should this become". The next four sections cover each in depth, with the real callsites that ship in core.
REGISTRY — add() + collect()
A plugin contributes one or more items to a named list. The host calls collect() to get an array of every contribution. Every callback runs, every return value is captured, in registration order.
Mechanic
// 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'];
}
Real REGISTRY hooks in core
| Hook key | Where the host collects it | What plugins contribute |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | Driver class metadata — type, driver (FQCN), label |
register_sending_server | app/Http/Controllers/Admin/SendingServerController.php:88 | "Add server" select-form metadata — icon, name, description, create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | Per-driver config-form field names — ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | Translation-file descriptor — locale folder, prefix, master file |
captcha_method | app/Model/Setting.php:290 | Captcha provider metadata — id, label, render closure |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | HTML banner fragments shown above the list-import dialog |
generate_big_notice_for_sending_server | app/Http/Controllers/Admin/SendingServerController.php:238 | HTML banners for the sending-server detail page |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | CSS / JS strings injected before |
layout.body.before_close | Same files, before </body> | Floating widget HTML — chatbox bubbles, modals, sparkle popovers |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Plugin-contributed admin sidebar sections |
When to use REGISTRY
- Multiple plugins might contribute (sending drivers, captcha methods, sidebar items).
- The host wants every contribution, not just the last one.
- The contributions compose without conflict — list items, menu entries, banner fragments, configuration metadata.
Naming convention
Inside the codebase, REGISTRY names tend to read as a verb-phrase that describes the contribution: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Names with a singular verb (register_*, add_*) signal "contribute one of these", but the collect() mechanic still gathers every contribution — there is nothing on the registration side that limits a plugin to a single entry.
EVENT — on() + fire()
A plugin reacts when something happens in the host. Return values are discarded — the contract is one-way: the core notifies, plugins compose side effects. Every listener runs, in registration order.
Mechanic
// 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]);
Real EVENT hooks fired by core
| Hook name | Where it fires | Args bag |
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 | Wired through 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 | (no args) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData] — defaults to false |
When to use EVENT
- The plugin wants to react but does not need to influence the core's outcome.
- Side effects only — sending webhooks, writing logs, awarding loyalty points, dispatching queue jobs.
- The core has already committed to the action by the time the event fires; the listener cannot cancel it.
EVENT and the $keepData flag. The delete_plugin_* event is the one place where the args bag carries an out-of-band signal. $keepData = true tells the listener "the admin wants to keep this plugin's data, skip migrate:rollback". The skeleton listener already respects it: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Plugins that own no preserveable data can ignore the arg with the default.
BEHAVIOR — set() + perform()
One callable owns the named behaviour. The host calls perform() to execute whoever is currently registered. set() claims the name exclusively — if a second caller tries to set the same name, HookManager::set() throws an exception immediately. There is no silent override; conflicts surface at boot, not in production.
Mechanic
// 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);
The setIfEmpty timing rule
The host's default goes through setIfEmpty(), not set() — the difference is intentional. setIfEmpty only registers the callback if no one else has claimed the name yet; if a plugin already called set in its boot(), the host's default is silently skipped.
That means setIfEmpty must be placed directly before perform(), in the controller or model, not in a service provider. The reason: by the time the controller's request handler runs, every plugin's ServiceProvider::boot() has finished — so any plugin override registered through set has already taken effect. Placing the default in register() or boot() would risk running before the plugin's set() and locking it out.
Real BEHAVIOR hooks in core
| Hook name | Where the host registers default + perform | What gets overridden |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | The job class queued when an admin imports a CSV — plugins can swap in a faster, distributed, or instrumented variant |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | The image URL rendered for the plugin entry on the admin Plugins page — defaults to /images/plugin.svg; plugins call Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) in their boot() |
When to use BEHAVIOR
- Exactly one piece of logic should run.
- A reasonable default exists, but a plugin should be able to swap it out completely.
- Two plugins claiming the same behaviour is a bug, not a feature — you want it to fail loudly.
BEHAVIOR exclusivity is a feature, not a problem. If two plugins legitimately need to influence the same behaviour, the right shape is REGISTRY (each contributes a candidate, the host picks one) or FILTER (each plugin transforms the value through a chain). Reaching for BEHAVIOR for "shared logic" forces an unwinnable race between two plugin authors. HookManager::set() throws with Behavior "{name}" has already been registered the moment the second plugin boots — so the conflict is impossible to ship into production.
FILTER — modify() + filter()
A value passes through a chain of callbacks. Each callback receives the current value (plus any extra positional args) and returns the value to pass to the next one. The host calls filter() with the initial value; the return is the final value after every plugin has had a turn.
Mechanic
// 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);
Optional positional args
Hook::filter($name, $value, $extraParams) accepts a third array of positional arguments that get passed to every callback alongside the current value. The contract example from the plugin guide is the maillist redirect:
// 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
When to use FILTER
- A value travels through several plugins before the host uses it.
- Each plugin can compose with the previous one — adding to a menu, modifying email content, conditioning a redirect, transforming a payload.
- Returning the unchanged input is the conventional opt-out — no exception, no special signal.
FILTER is the least-exercised pattern in the current core code base. The HookManager implementation is stable (lines 143-159 of the file), and the contract is documented for plugin authors, but the core does not yet call Hook::filter in production hot paths beyond the documented page.maillist.show.redirect example. New host-side hot paths that want plugin-composable transforms should reach for FILTER over BEHAVIOR — the chain semantics are exactly what most "I want plugins to be able to add to this" situations need.
Conflict semantics across the four patterns
When two plugins target the same hook name, the four patterns react differently. Knowing which kind of pattern you have tells you exactly what conflict looks like, and whether it will surface at boot or much later.
| Pattern | What two plugins targeting the same name do | How surfaces |
| REGISTRY | Both contributions are kept; collect returns both | No conflict — by design. Order is registration order. |
| EVENT | Both listeners run; side effects compose | No conflict — by design. Order is registration order. |
| BEHAVIOR | The second set call throws with Behavior "{name}" has already been registered | Boot-time exception — the plugin's ServiceProvider::boot() fails, the master file records the error, the admin Plugins page surfaces a red pill. Production never sees the silent override. |
| FILTER | The value passes through both callbacks in registration order | No conflict — by design. Each callback can opt out by returning the input unchanged. |
Three of the four patterns are conflict-free because they aggregate. BEHAVIOR is the only one with exclusive ownership, and the failure mode is a hard exception at boot — a deliberate design choice so a misuse cannot coexist with a working install.
The args-bag convention
Every fire / collect / perform / filter takes the same shape: $name, $value (or default), $params. The $params array is unpacked positionally into the registered callback. Every callback in the chain receives the same args.
- Keep args bags small and stable. Once a hook ships, the args become a contract — adding a positional arg breaks every plugin that already binds the closure with a fixed signature.
- Pass models, not field bags.
fire('customer_added', [$customer]) lets the listener decide which fields to read; fire('customer_added', [$customer->email, $customer->uid, ...]) would lock the args into whatever fields existed at the time the hook shipped.
- Use additional hooks instead of overloading args. If a slot needs richer context, register a separate hook key rather than adding a fifth optional positional arg.
The by-reference collect quirk
A small slice of the core uses collect() with by-reference arguments as a mutation hook — outside the canonical four-pattern model. The filter_aws_ses_dns_records callsite is the canonical example:
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
The host fires the hook expecting plugins to mutate $dkims and $spf in place. Strictly speaking this is a misuse of REGISTRY — a true FILTER chain would have been the right shape. The behaviour works because PHP closures honour by-reference args, but plugin authors should not write new callsites in this style. Reach for FILTER (single value transformed) or REGISTRY (multiple immutable contributions) instead.
Six anti-patterns to avoid
The patterns below all look right at first glance and break in subtle ways. Each is grounded in a class of bug already seen in production plugins.
1. Registering hooks in register() when they need boot()
The host's register() runs before any service provider's boot(). Hooks registered there fire before dependencies wired up by other providers' register(). Symptom: Class not found on the very first request, before any plugin code executes its main path. Fix: only the add_translation_file hook belongs in register(); everything else goes in boot().
2. Reaching for BEHAVIOR when "shared" is the goal
BEHAVIOR throws on a second set. If two plugins legitimately need to influence the same point, FILTER (compose) or REGISTRY (collect) are the right shape. Fix: rewrite the hook contract — emit a chain or a list, not an exclusive callable.
3. Putting setIfEmpty in a service provider
setIfEmpty only takes effect if no one else has registered yet. Placing it in register() or boot() means it might run before a plugin's set(), locking the plugin out. Fix: place setIfEmpty directly above the matching perform call, in the controller or model, so every plugin's boot() has already finished.
4. Mutating shared state inside a REGISTRY callback
collect calls every callback in registration order. A callback that writes to a shared cache or session as a side effect makes the hook non-deterministic — running it twice with different plugin order gives different cache state. Fix: keep add callbacks pure. If the contribution depends on side effects, register an EVENT listener separately.
5. Adding positional args to an existing hook
Once a hook is in production, plugins have already bound closures with the original arity. Adding a fifth positional arg breaks every binding that omitted it. Fix: register a new hook name (customer_added_v2) and accept a transition window, or pass the new context through the model object that is already in the args bag.
6. Using collect() for a single answer
REGISTRY returns an array — even when only one plugin registers, the host gets [$result]. Treating that as the answer ($first = Hook::collect(...)[0]) silently picks whichever plugin booted first, with no conflict semantics. Fix: use BEHAVIOR if exactly one answer is expected.
How to pick a pattern
The decision usually collapses to four questions. Answering them in order picks the right shape:
- Should multiple plugins compose? If yes, REGISTRY (independent contributions) or FILTER (chained transform). If no, BEHAVIOR (exclusive override).
- Does the host need a return value? If no, EVENT (side effects only). If yes, REGISTRY / BEHAVIOR / FILTER.
- Does the value pass through several hands? If yes, FILTER (chain). If each hand contributes independently, REGISTRY (collect).
- Is conflict between two plugins a bug? If yes, BEHAVIOR (loud failure at boot). If no, the conflict-free patterns.
When in doubt, pick the looser pattern. REGISTRY plus an "out of band" EVENT for cleanup is almost always more flexible than BEHAVIOR — the conflict semantics are what kill BEHAVIOR in practice, and the loose patterns let plugins compose without coordination.
Where to go next
You now have the four patterns at depth and the conflict semantics that make each shape predictable. Three pages turn this knowledge into the daily-use surfaces a real plugin will actually touch:
- UI injection — the layout-level REGISTRY hooks (
layout.head.assets, layout.body.before_close, admin.sidebar.groups) and the page.{controller}.{action}.{slot} contract for injecting cards into specific pages without forking a single Blade.
- Sending drivers — the worked example that combines
register_sending_server_driver (REGISTRY) with the activate_plugin_* + delete_plugin_* events to ship a brand-new MTA backend as a plugin.
- Payment gateways — the parallel pattern using the Cashier package's contracts plus the host's plugin REGISTRY to add a regional or crypto gateway.