Die Vier-Pattern-Karte
Jeder Hook im Codebase fällt in genau eine von vier Formen. Die Form bestimmt die Konfliktsemantik, die Behandlung des Rückgabewerts und welche Art von Interaktion zwischen Core und Plugins sinnvoll ist. Zum falschen Pattern zu greifen zeigt sich später als merkwürdiger Sonderfall — vorab zu wissen, welches welches ist, spart später eine Neufassung der Integration.
| Pattern | Register | Execute | Liefert zurück | Konflikt |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | Array mit dem Rückgabewert jedes Callbacks | Merge — jeder Callback läuft, jedes Ergebnis bleibt erhalten |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | nichts — Rückgabewerte werden verworfen | All-fire — jeder Listener läuft, Seiteneffekte komponieren sich |
| BEHAVIOR | Hook::set($name, $cb) oder setIfEmpty | Hook::perform($name, [...args]) | genau das, was der einzelne registrierte Callback zurückgibt | Exklusiv — ein zweites set auf denselben Namen wirft sofort eine Exception |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | der Wert, durch jeden Callback in Registrierungsreihenfolge transformiert | Chain — jeder Callback erhält die Ausgabe des vorherigen |
Ein nützliches Denkmodell: REGISTRY beantwortet „Was wollen Sie beitragen?"; EVENT beantwortet „Wollten Sie es wissen?"; BEHAVIOR beantwortet „Wie soll ich das tun?"; FILTER beantwortet „Was soll das werden?". Die nächsten vier Abschnitte behandeln jedes Pattern im Detail, mit den realen Callsites, die im Core ausgeliefert werden.
REGISTRY — add() + collect()
Ein Plugin steuert ein oder mehrere Items zu einer benannten Liste bei. Der Host ruft collect() auf, um ein Array sämtlicher Beiträge zu erhalten. Jeder Callback läuft, jeder Rückgabewert wird erfasst — in Registrierungsreihenfolge.
Mechanik
// 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'];
}
Echte REGISTRY-Hooks im Core
| Hook-Key | Wo der Host sie einsammelt | Was Plugins beitragen |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | Treiberklassen-Metadaten — type, driver (FQCN), Label |
register_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:120 | Metadaten für das „Server hinzufügen"-Formular — Icon, Name, Beschreibung, create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | Pro-Treiber-Feldnamen für das Konfigurations-Formular — ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | Deskriptor für eine Übersetzungsdatei — Locale-Ordner, Prefix, Master-Datei |
captcha_method | app/Model/Setting.php:290 | Metadaten für Captcha-Provider — id, Label, Render-Closure |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | HTML-Banner-Fragmente, die über dem Listenimport-Dialog erscheinen |
generate_big_notice_for_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:235 | HTML-Banner für die Sending-Server-Detailseite |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | CSS-/JS-Strings, die vor @yield('head') eingefügt werden |
layout.body.before_close | Same files, before </body> | HTML für schwebende Widgets — Chatbox-Bubbles, Modals, Sparkle-Popovers |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Vom Plugin beigesteuerte Admin-Sidebar-Sektionen |
Wann Sie REGISTRY einsetzen
- Mehrere Plugins könnten beitragen (Sending-Treiber, Captcha-Methoden, Sidebar-Einträge).
- Der Host will jeden Beitrag, nicht nur den letzten.
- Die Beiträge komponieren sich konfliktfrei — Listenelemente, Menüeinträge, Banner-Fragmente, Konfigurations-Metadaten.
Namenskonvention
Innerhalb des Codebase lesen sich REGISTRY-Namen meist als Verbphrase, die den Beitrag beschreibt: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Namen mit einem Singular-Verb (register_*, add_*) signalisieren „eines davon beitragen" — doch die collect()-Mechanik sammelt trotzdem jeden Beitrag ein. Auf der Registrierungsseite gibt es nichts, was ein Plugin auf einen einzelnen Eintrag begrenzt.
EVENT — on() + fire()
Ein Plugin reagiert, wenn im Host etwas geschieht. Rückgabewerte werden verworfen — der Vertrag ist einseitig: der Core benachrichtigt, Plugins komponieren Seiteneffekte. Jeder Listener läuft, in Registrierungsreihenfolge.
Mechanik
// 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]);
Echte EVENT-Hooks, die der Core auslöst
| Hook-Name | Wo er ausgelöst wird | 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 | Verdrahtet über den 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 | (keine Args) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData] — Default false |
Wann Sie EVENT einsetzen
- Das Plugin will reagieren, muss aber das Ergebnis des Cores nicht beeinflussen.
- Nur Seiteneffekte — Webhooks versenden, Logs schreiben, Loyalty-Punkte vergeben, Queue-Jobs dispatchen.
- Der Core hat sich zum Zeitpunkt des Event-Feuerns bereits zur Aktion verpflichtet; der Listener kann sie nicht abbrechen.
EVENT und das $keepData-Flag. Das delete_plugin_*-Event ist die einzige Stelle, an der die Args-Bag ein Out-of-Band-Signal trägt. $keepData = true teilt dem Listener mit: „Der Admin möchte die Daten dieses Plugins behalten, überspringe migrate:rollback". Der Skeleton-Listener respektiert das bereits: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Plugins ohne erhaltenswerte Daten können das Argument mit dem Default ignorieren.
BEHAVIOR — set() + perform()
Ein Callable besitzt das benannte Verhalten. Der Host ruft perform() auf, um auszuführen, wer aktuell registriert ist. set() beansprucht den Namen exklusiv — versucht ein zweiter Aufrufer, denselben Namen mit set zu belegen, wirft HookManager::set() sofort eine Exception. Es gibt kein stilles Überschreiben; Konflikte tauchen beim Boot auf, nicht in der Produktion.
Mechanik
// 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);
Die Timing-Regel für setIfEmpty
Der Default des Hosts läuft über setIfEmpty(), nicht über set() — der Unterschied ist beabsichtigt. setIfEmpty registriert den Callback nur, wenn noch niemand den Namen beansprucht hat; hat ein Plugin in seinem boot() bereits set aufgerufen, wird der Default des Hosts still übersprungen.
Das bedeutet: setIfEmpty muss direkt vor perform() stehen — im Controller oder Model, nicht in einem Service Provider. Der Grund: Sobald der Request-Handler im Controller läuft, hat jeder Plugin-ServiceProvider::boot() bereits abgeschlossen — also hat jedes über set registrierte Plugin-Override bereits Wirkung entfaltet. Den Default in register() oder boot() zu platzieren, würde riskieren, vor dem set() des Plugins zu laufen und es auszusperren.
Echte BEHAVIOR-Hooks im Core
| Hook-Name | Wo der Host Default + perform registriert | Was überschrieben wird |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | Die Job-Klasse, die in die Queue gestellt wird, wenn ein Admin eine CSV importiert — Plugins können eine schnellere, verteilte oder instrumentierte Variante einbringen |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | Die Bild-URL, die für den Plugin-Eintrag auf der Admin-Plugins-Seite gerendert wird — Default /images/plugin.svg; Plugins rufen in ihrem boot() Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) auf |
Wann Sie BEHAVIOR einsetzen
- Genau ein Stück Logik soll laufen.
- Ein sinnvoller Default existiert, aber ein Plugin soll ihn vollständig austauschen können.
- Dass zwei Plugins dasselbe Verhalten beanspruchen, ist ein Bug, kein Feature — Sie wollen, dass es laut fehlschlägt.
Die BEHAVIOR-Exklusivität ist ein Feature, kein Problem. Wenn zwei Plugins legitim dasselbe Verhalten beeinflussen müssen, ist die richtige Form REGISTRY (jedes steuert einen Kandidaten bei, der Host wählt einen aus) oder FILTER (jedes Plugin transformiert den Wert in einer Kette). Zu BEHAVIOR für „gemeinsame Logik" zu greifen, erzwingt einen unauflöslichen Wettlauf zwischen zwei Plugin-Autoren. HookManager::set() wirft mit Behavior "{name}" has already been registered, sobald das zweite Plugin bootet — der Konflikt kann es also nicht in die Produktion schaffen.
FILTER — modify() + filter()
Ein Wert passiert eine Kette von Callbacks. Jeder Callback erhält den aktuellen Wert (plus etwaige zusätzliche positionale Args) und gibt den Wert für den nächsten zurück. Der Host ruft <code>filter()</code> mit dem Anfangswert auf; der Rückgabewert ist der Endwert, nachdem jedes Plugin an der Reihe war.
Mechanik
// 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);
Optionale positionale Args
Hook::filter($name, $value, $extraParams) akzeptiert ein drittes Array mit positionalen Argumenten, das jedem Callback neben dem aktuellen Wert übergeben wird. Das Vertragsbeispiel aus dem Plugin-Guide ist der 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
Wann Sie FILTER einsetzen
- Ein Wert durchläuft mehrere Plugins, bevor der Host ihn verwendet.
- Jedes Plugin kann auf dem Vorgänger aufbauen — ein Menü erweitern, E-Mail-Inhalt anpassen, einen Redirect bedingen, einen Payload transformieren.
- Den unveränderten Input zurückzugeben ist das konventionelle Opt-out — keine Exception, kein Sondersignal.
FILTER ist das am wenigsten genutzte Pattern in der aktuellen Core-Codebasis. Die HookManager-Implementierung ist stabil (Zeilen 143-159 der Datei), und der Vertrag ist für Plugin-Autoren dokumentiert — aber jenseits des dokumentierten Beispiels page.maillist.show.redirect ruft der Core Hook::filter noch nicht in produktiven Hot Paths auf. Neue host-seitige Hot Paths, die plugin-komponierbare Transformationen wollen, sollten zu FILTER statt zu BEHAVIOR greifen — die Chain-Semantik ist genau das, was die meisten „Ich will, dass Plugins hier ergänzen können"-Situationen brauchen.
Konfliktsemantik der vier Patterns
Wenn zwei Plugins denselben Hook-Namen anvisieren, reagieren die vier Patterns unterschiedlich. Zu wissen, welches Pattern Sie haben, sagt Ihnen genau, wie ein Konflikt aussieht und ob er beim Boot oder viel später sichtbar wird.
| Pattern | Was zwei Plugins mit demselben Namen tun | Wie es sichtbar wird |
| REGISTRY | Beide Beiträge bleiben erhalten; collect liefert beide zurück | Kein Konflikt — by design. Reihenfolge ist Registrierungsreihenfolge. |
| EVENT | Beide Listener laufen; Seiteneffekte komponieren sich | Kein Konflikt — by design. Reihenfolge ist Registrierungsreihenfolge. |
| BEHAVIOR | Der zweite set-Aufruf wirft mit Behavior "{name}" has already been registered | Exception beim Boot — der ServiceProvider::boot() des Plugins schlägt fehl, die Master-Datei verzeichnet den Fehler, die Admin-Plugins-Seite zeigt eine rote Pille. Die Produktion sieht das stille Überschreiben nie. |
| FILTER | Der Wert durchläuft beide Callbacks in Registrierungsreihenfolge | Kein Konflikt — by design. Jeder Callback kann sich abmelden, indem er den Input unverändert zurückgibt. |
Drei der vier Patterns sind konfliktfrei, weil sie aggregieren. BEHAVIOR ist das einzige mit exklusivem Besitzanspruch, und die Fehlermode ist eine harte Exception beim Boot — eine bewusste Designentscheidung, damit ein Missbrauch nicht parallel zu einer funktionierenden Installation existieren kann.
Die args-bag-Konvention
Jedes fire / collect / perform / filter folgt derselben Form: $name, $value (oder Default), $params. Das $params-Array wird positional in den registrierten Callback entpackt. Jeder Callback in der Kette erhält dieselben Args.
- Halten Sie Args-Bags klein und stabil. Sobald ein Hook ausgeliefert ist, werden die Args zu einem Vertrag — ein positionales Arg hinzuzufügen, bricht jedes Plugin, das die Closure bereits mit fester Signatur gebunden hat.
- Übergeben Sie Models, keine Feld-Bündel.
fire('customer_added', [$customer]) lässt den Listener entscheiden, welche Felder er liest; fire('customer_added', [$customer->email, $customer->uid, ...]) würde die Args auf genau die Felder festlegen, die zum Auslieferungszeitpunkt existierten.
- Verwenden Sie zusätzliche Hooks statt überfrachteter Args. Braucht ein Slot reicheren Kontext, registrieren Sie einen separaten Hook-Key statt ein fünftes optionales positionales Arg anzuhängen.
Die By-Reference-Eigenheit von collect
Ein kleiner Ausschnitt des Cores verwendet collect() mit By-Reference-Argumenten als Mutations-Hook — außerhalb des kanonischen Vier-Pattern-Modells. Die Callsite filter_aws_ses_dns_records ist das kanonische Beispiel:
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
Der Host feuert den Hook in der Erwartung, dass Plugins $dkims und $spf an Ort und Stelle mutieren. Streng genommen ist das ein Missbrauch von REGISTRY — eine echte FILTER-Kette wäre die richtige Form gewesen. Das Verhalten funktioniert, weil PHP-Closures By-Reference-Args respektieren — Plugin-Autoren sollten neue Callsites jedoch nicht in diesem Stil schreiben. Greifen Sie stattdessen zu FILTER (ein einzelner transformierter Wert) oder REGISTRY (mehrere unveränderliche Beiträge).
Sechs Anti-Patterns, die Sie vermeiden sollten
Die folgenden Patterns wirken auf den ersten Blick richtig und brechen auf subtile Weise. Jedes stützt sich auf eine Bug-Klasse, die bereits in produktiven Plugins zu sehen war.
1. Hooks in register() registrieren, obwohl sie in boot() gehören
Das register() des Hosts läuft vor dem boot() jedes Service Providers. Dort registrierte Hooks feuern, bevor Abhängigkeiten verdrahtet sind, die durch das register() anderer Provider entstehen. Symptom: Class not found beim allerersten Request, bevor irgendein Plugin-Code seinen Hauptpfad ausführt. Fix: Nur der Hook add_translation_file gehört in register(); alles andere geht in boot().
2. Zu BEHAVIOR greifen, wenn „shared" das Ziel ist
BEHAVIOR wirft bei einem zweiten set. Wenn zwei Plugins legitim denselben Punkt beeinflussen müssen, sind FILTER (komponieren) oder REGISTRY (sammeln) die richtige Form. Fix: Schreiben Sie den Hook-Vertrag um — geben Sie eine Kette oder eine Liste aus, kein exklusives Callable.
3. setIfEmpty in einem Service Provider platzieren
setIfEmpty greift nur, wenn sich noch niemand registriert hat. Es in register() oder boot() zu platzieren bedeutet, dass es vor dem set() eines Plugins laufen kann und das Plugin aussperrt. Fix: Platzieren Sie setIfEmpty unmittelbar über dem zugehörigen perform-Aufruf, im Controller oder Model — so hat das boot() jedes Plugins bereits abgeschlossen.
4. Gemeinsamen State innerhalb eines REGISTRY-Callbacks mutieren
collect ruft jeden Callback in Registrierungsreihenfolge auf. Ein Callback, der als Seiteneffekt in einen geteilten Cache oder eine Session schreibt, macht den Hook nicht-deterministisch — zweimal mit unterschiedlicher Plugin-Reihenfolge ausgeführt, ergibt unterschiedliche Cache-Zustände. Fix: Halten Sie add-Callbacks rein. Hängt der Beitrag von Seiteneffekten ab, registrieren Sie separat einen EVENT-Listener.
5. Positionale Args zu einem bestehenden Hook hinzufügen
Sobald ein Hook in Produktion ist, haben Plugins ihre Closures bereits mit der ursprünglichen Arität gebunden. Ein fünftes positionales Arg bricht jede Bindung, die es weggelassen hat. Fix: Registrieren Sie einen neuen Hook-Namen (customer_added_v2) und akzeptieren Sie ein Übergangsfenster — oder reichen Sie den neuen Kontext durch das Model-Objekt durch, das ohnehin schon in der Args-Bag liegt.
6. collect() für eine einzelne Antwort verwenden
REGISTRY liefert ein Array zurück — auch wenn nur ein Plugin registriert ist, bekommt der Host [$result]. Das als die Antwort zu behandeln ($first = Hook::collect(...)[0]) wählt still das Plugin, das zuerst gebootet hat, ohne jede Konfliktsemantik. Fix: Verwenden Sie BEHAVIOR, wenn genau eine Antwort erwartet wird.
Wie Sie ein Pattern auswählen
Die Entscheidung lässt sich meist auf vier Fragen reduzieren. In Reihenfolge beantwortet, wählen sie die richtige Form:
- Sollen sich mehrere Plugins komponieren? Wenn ja, REGISTRY (unabhängige Beiträge) oder FILTER (verkettete Transformation). Wenn nein, BEHAVIOR (exklusives Override).
- Braucht der Host einen Rückgabewert? Wenn nein, EVENT (nur Seiteneffekte). Wenn ja, REGISTRY / BEHAVIOR / FILTER.
- Geht der Wert durch mehrere Hände? Wenn ja, FILTER (Kette). Wenn jede Hand unabhängig beiträgt, REGISTRY (sammeln).
- Ist ein Konflikt zwischen zwei Plugins ein Bug? Wenn ja, BEHAVIOR (lautes Versagen beim Boot). Wenn nein, die konfliktfreien Patterns.
Im Zweifel wählen Sie das losere Pattern. REGISTRY plus ein „Out of band"-EVENT für Aufräumarbeiten ist fast immer flexibler als BEHAVIOR — die Konfliktsemantik ist es, die BEHAVIOR in der Praxis zerstört, und die losen Patterns lassen Plugins ohne Koordination komponieren.
Wie es weitergeht
Sie kennen jetzt die vier Patterns im Detail und die Konfliktsemantik, die jede Form vorhersagbar macht. Drei Seiten verwandeln dieses Wissen in die tagtäglich genutzten Oberflächen, die ein echtes Plugin tatsächlich berührt: