Warum UI-Injection existiert
Ein Plugin, das ein Feature ergänzt, muss dieses Feature in der Regel irgendwo in der Host-UI sichtbar machen. Die naiven Optionen sind unterschiedlich schlecht: Die Blade-Layouts des Hosts zu forken, zwingt das Plugin, eine eigene Kopie über jedes Host-Upgrade hinweg zu pflegen; sie zur Installationszeit zu patchen, lässt den Host-Quellcode mit dem in der Produktion laufenden Stand desynchronisieren. Beides bindet Plugin und Host an eine Wartungslast, die mit jedem Release wächst.
Das Plugin-System umgeht diesen Kompromiss, indem es benannte Slots in den Master-Layouts des Hosts reserviert. Jeder Slot ist ein REGISTRY-Hook — jedes registrierte Plugin steuert einen HTML-String bei, der Host sammelt sie zur Rendering-Zeit ein, filtert die Falsy-Beiträge heraus und gibt jedes Fragment in Registrierungsreihenfolge aus. Plugins sehen den Blade-Quellcode des Hosts nie; der Host weiß nie, welches Plugin was beigesteuert hat.
Die drei Layout-Slots
Drei REGISTRY-Hooks feuern aus den Master-Layouts app und admin. Zusammen decken sie nahezu jede UI-Erweiterung ab, die ein Plugin je benötigen wird — Head-Of-Document-Assets, Body-End-Widgets und Admin-Sidebar-Gruppen.
| Hook-Key | Wo die Callsite liegt | Args-Bag | Verwendet für |
layout.head.assets |
resources/views/refactor/layouts/{app,admin}.blade.php, kurz vor @yield('head') |
[$layout, $context] |
<link>- / <style>- / <script>-Tags, die vor seitenspezifischem Inhalt geladen sein müssen — Chatbox-CSS, Sparkle-Popover-Skripte |
layout.body.before_close |
Dieselben Dateien, kurz vor </body> |
[$layout, $context] |
Schwebende Widgets, die einmal pro Seite mounten — Chatbox-Bubble, Modal-Overlays, Sparkle-Popovers |
admin.sidebar.groups |
resources/views/refactor/components/nav/admin-sidebar.blade.php |
(keine Args) |
Vom Plugin beigesteuerte Admin-Sidebar-Sektionen — jeder Eintrag rendert als <div class="mc-nav-group">...</div>-Fragment |
Alle drei werden host-seitig über dasselbe Idiom eingesammelt. Das Blade-Snippet, das in resources/views/refactor/layouts/admin.blade.php ausgeliefert wird, lautet:
@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
{!! $html !!}
@endforeach
Drei Dinge sollten Sie aus diesem Snippet mitnehmen: collect nimmt eine Args-Bag entgegen (hier ['admin']), array_filter verwirft jeden Beitrag, der null / false / '' ist, und das überlebende HTML wird mit {!! !!} ausgegeben — unescaped, weil es bereits gerendertes Blade ist.
Der Vertrag — HTML oder null zurückgeben
Ein REGISTRY-Callback für einen der drei Layout-Slots gibt eines von zwei Dingen zurück:
- Einen HTML-String — typischerweise das Ergebnis von
view('myname::partials.foo')->render(). Der Host gibt ihn unverändert mit {!! !!} aus.
null (oder einen anderen Falsy-Wert — false, '', 0). Das array_filter des Hosts verwirft ihn. So gaten Sie konventionell einen Beitrag per Feature-Flag, Plugin-Status, Environment oder Per-Request-Kontext.
null zurückzugeben ist vorzuziehen gegenüber gar nicht erst zu registrieren. boot() eines Plugins läuft einmal pro Prozess; ob beigetragen werden soll, sollte pro Render entschieden werden, nicht beim Boot. Der aiPluginAvailable()-Check in storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 ist das kanonische Beispiel — die Closure springt direkt auf null, sobald das AI-Modul abgeschaltet ist, und lässt jeden anderen Plugin-Beitrag unangetastet.
Zurückgegebenes HTML muss in sich abgeschlossen sein. Der Host fügt das Fragment an der Callsite ohne weiteres Escaping oder Wrapping in das Dokument ein. Alles, worauf das Fragment angewiesen ist — CSS, JS, Schriftdateien — muss zum Zeitpunkt des Renderings bereits geladen sein. Genau deshalb gibt es layout.head.assets zusätzlich zu layout.body.before_close: Head-Fragmente laden zuerst, Body-Fragmente mounten zuletzt, und das Plugin kann seine Asset-Registrierung bei Bedarf auf beide Slots aufteilen.
Die Args-Bag — $layout und $context
layout.head.assets und layout.body.before_close übergeben beide zwei positionale Args: $layout (ein String, der angibt, welches Master-Layout den Slot ausgelöst hat — 'app', 'admin' usw.) und $context (ein optionales Array mit oberflächen-spezifischen Props, die das Layout zur Verfügung stellt).
// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
Der einzelne geteilte Hook-Key feuert aus jedem Master-Layout — app, admin, der E-Mail-Builder, der Form-Builder, der Automation-Editor. Der Partial des Plugins dispatcht intern anhand von $layout, um das richtige Asset-Set oder die richtige Chatbox-Konfiguration zu rendern. Es gibt keinen separaten layout.app.head.assets-/layout.admin.head.assets-Hook; der Layout-Name ist nur ein Discriminator innerhalb einer geteilten Bag.
Weitere positionale Args zu einem bestehenden Layout-Slot hinzuzufügen, würde jedes Plugin brechen, das die Closure bereits mit der ursprünglichen Arität gebunden hat. Neuer Kontext gehört in das $context-Array (das ohne Signaturänderung wachsen kann) oder hinter einen separaten Hook-Key. Die hauseigenen aiHooks-Beiträge handhaben das exakt so — der Builder und der Automation-Editor reichen Oberflächen-Props über $context durch, und das Plugin liest bei Bedarf $context['kind'], $context['task'] usw.
Beispiel — die acelle/ai-Chatbox-Bubble
Die kanonische Referenz für Layout-Injection liegt in storage/app/plugins/acelle/ai/src/ServiceProvider.php, Zeilen 678-728. Das Plugin steuert zu allen drei Layout-Slots bei, jeweils hinter demselben aiPluginAvailable()-Gate. Der vollständige Registrierungsblock, im Sinne des obigen Vertrags paraphrasiert:
// In acelle/ai's ServiceProvider::boot()
private function registerLayoutInjections(): void
{
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.body_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
}
private function registerAdminSidebarSection(): void
{
Hook::add('admin.sidebar.groups', function () {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.admin_sidebar_group')->render();
});
}
Was aus diesen drei Blöcken in der Produktion ankommt: Jede Seite, die das Layout app oder admin erweitert, erhält das Chatbox-CSS/-JS im <head>, das Chatbox-Bubble-HTML vor </body> und (nur auf Admin-Seiten) eine „AI"-Sidebar-Gruppe, die nach den eingebauten Gruppen des Hosts gerendert wird. Keine Blade-Datei des Hosts wurde verändert, damit das funktioniert — das Plugin steuert über die geteilten Hook-Keys bei, der Host rendert, was ankommt.
Das Plugin gated den Beitrag mit aiPluginAvailable(), das wiederum ai_plugin_active() prüft — ein Helper, der letztlich auf Plugin::getByName('acelle/ai')->isActive() hinausläuft. Sobald ein Admin das Plugin auf der Admin-Plugins-Seite deaktiviert, gibt jeder Callback beim nächsten Request null zurück, und Chatbox + Sparkle-UI verschwinden — ohne Routen neu zu laden, registrierte Services abzubauen oder Caches zu invalidieren.
admin.sidebar.groups ist der einfachste der drei Layout-Hooks: keine Args, der Callback gibt ein in sich abgeschlossenes <div class="mc-nav-group">...</div>-Fragment zurück. Der Host rendert es nach den eingebauten Gruppen (Kunden, Tarife, Einstellungen, ...) und vor jeder schließenden Layout-Struktur. Reihenfolge ist Registrierungsreihenfolge, daher sollten Plugins, die eine bestimmte Render-Position erkämpfen wollen, spät in boot() nach allen Abhängigkeiten registrieren.
Die Sidebar-Gruppe von acelle/ai liegt in resources/views/partials/admin_sidebar_group.blade.php im Plugin und rendert eine "AI"-Gruppe mit drei oder vier Children, je nach Plan-Flags. Dasselbe Pattern eignet sich für jedes Plugin, das eine eigene oberste Admin-Sektion braucht — ein Loyalty-Points-Plugin, ein Payment-Gateway-Plugin, ein regionales Sending-Treiber-Plugin.
Page-Slots — page.{controller}.{action}.{slot}
Layout-Ebene-Injection deckt ab, was auf jeder Seite erscheinen soll; Page-Slots decken ab, was auf einer bestimmten erscheinen soll. Die Namenskonvention macht die Bindung explizit:
page.{controller_slug}.{action}.{slot}
Beispiele:
page.maillist.show.body — zusätzliche Cards, die im Body der Maillist-Detailseite gerendert werden
page.maillist.verification.body — Inhalt, der über dem Verifikationsstatus-Block gerendert wird
page.campaign.index.sidebar — Sidebar-Ergänzungen auf der Kampagnen-Index-Seite
page.customer.edit.footer — Footer-Ergänzungen auf der Kunde-Bearbeiten-Seite
Die Callsite des Hosts sieht genauso aus wie bei den Layout-Slots — collect, array_filter, jedes Fragment mit {!! !!} ausgeben:
@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
{!! $html !!}
@endforeach
Und der Plugin-Beitrag sieht genauso aus wie der eines Layout-Slots, mit der slot-spezifischen Args-Bag, die durchgereicht wird:
// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
$points = LoyaltyPoints::getTotalForList($list);
return view('loyalty::list_points_card', [
'list' => $list,
'points' => $points,
])->render();
});
Args-Bags von Page-Slots tragen meist das relevante Model — die Mailing-Liste, die Kampagne, den Kunden — damit das Plugin alles Nötige ohne zusätzliche Datenbankabfrage lesen kann. Diese Konvention hält die Hook-Signatur über Model-Evolutionen des Hosts hinweg stabil: Ein neues Feld an MailList hinzuzufügen, bricht keine Plugin-Hook-Signatur, weil das Plugin immer das Model erhält.
Page-Redirects — die FILTER-Variante
Eine Handvoll Page-Hooks verwendet statt REGISTRY das FILTER-Pattern — der typische Fall ist „Plugins entscheiden lassen, ob der User vor dem Core-Rendering umgeleitet werden soll". Der Vertrag:
// Core controller
$redirect = \App\Library\Facades\Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
return $redirect;
}
// continue rendering the page
// Plugin (in ServiceProvider::boot())
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"
});
Die Form ist FILTER (verkettete Transformation eines Werts), nicht REGISTRY (mehrere unabhängige Beiträge), weil pro Request nur ein Redirect passieren kann. Den unveränderten Input aus der Closure zurückzugeben ist das konventionelle Opt-out — das nächste Plugin in der Kette sieht, was das vorherige entschieden hat. Der erste Non-Null-Wert gewinnt, weil der Controller nach Abschluss der Filter-Kette if ($redirect) prüft.
Genau dieses Pattern nutzt athena/evs, um den User auf seine eigene Verifikationsseite zu routen, wenn das E-Mail-Verifikations-Plugin die Maillist-Verifikations-Oberfläche übernehmen muss. Die volle Mechanik von FILTER liegt im Hook-System-Deep-Dive; die praktische Erkenntnis hier: Page-Redirects nutzen FILTER, Page-Rendering nutzt REGISTRY.
Asset-Publishing — CSS / JS / Bilder mit dem Plugin bündeln
Layout-Slots betreffen, wo ein Plugin rendert. Asset-Publishing betrifft, was das gerenderte HTML referenzieren darf. Plugins, die CSS, JavaScript, Schriften oder Bilder ausliefern, nutzen die Standard-publishes()-API von Laravel, mit dem 'plugin'-Tag, das der Host kennt:
// In ServiceProvider::boot()
$this->publishes([
__DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');
Bei jedem Plugin::register() ruft der Host artisan vendor:publish --tag=plugin --force auf, was den resources/assets/-Baum des Plugins nach public/plugins/{vendor}/{name}/ kopiert. Die eigenen Partials des Plugins referenzieren Assets über diesen Pfad:
<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">
Die Icon-Serving-Route aus routes.php (die benannte Route plugin.{vendor}.{name}.icon) ist die Alternative — statt eine statische SVG nach public/ zu publishen, kann das Plugin eine HTTP-Route bereitstellen, die sein Icon direkt aus storage/app/plugins/{vendor}/{name}/icon.svg streamt. Der Trade-off: Der publishte Pfad ist schneller (CDN-cacheable), verlangt aber bei jeder Installation ein Publish; der geroutete Pfad ist in sich abgeschlossen, zahlt aber pro Request einen Laravel-Boot.
Anti-Patterns
1. Statt eines Strings ein Blade-View-Objekt zurückgeben
Der Host gibt mit {!! !!} alles aus, was die Closure zurückgibt. view('foo') zurückzugeben gibt das __toString des Objekts aus, was meistens funktioniert, aber der Closure die Chance nimmt, Render-Fehler sauber zu behandeln. Fix: Rufen Sie immer ->render() auf und geben Sie den resultierenden String zurück, genau wie es die acelle/ai-Registrierungen tun.
2. Den gating-null-Zweig vergessen
Ein REGISTRY-Callback, der immer HTML zurückgibt, steuert weiter bei — egal ob das Plugin aktiv ist oder nicht — weil autoloadWithoutDbQuery() auch inaktive Plugins lädt (siehe Plugin-Architektur § Warum inaktive Plugins die App weiter beeinflussen). Fix: Schützen Sie mit Plugin::enabled('myvendor/myplugin') oder einem Feature-Flag-Helper am Anfang jeder Closure und geben Sie null zurück, wenn abgeschaltet.
3. collect() mit zusätzlichen Args aufrufen, die der Host nicht übergeben hat
Plugins rufen Hook::collect nicht selbst auf — das macht der Host. Braucht ein Plugin den Layout-Namen für eine eigene Entscheidung, liest es ihn aus dem ersten Arg der Closure. Einen Layout-Slot aus einem Plugin heraus mit Hook::collect aufzurufen, führt den Callback jedes anderen Plugins ein zusätzliches Mal aus. Fix: Die Closure erhält alle Args, die sie braucht — rufen Sie collect niemals aus einem registrierten Handler erneut auf.
4. Blockierende Arbeit innerhalb der Closure rendern
Die Closure läuft einmal pro Page-Render — ein Stripe-API-Call oder eine 200ms-DB-Query darin schlägt diese Kosten auf jeden Request auf. Fix: Vorausberechnen, cachen oder auf einen asynchronen Loader auslagern. Das Fragment kann einen <div data-async-loader>-Platzhalter zurückgeben, den das Plugin-JS aus einem Hintergrund-Fetch hydratisiert.
5. Seiteneffekte in einem REGISTRY-Callback
collect ruft jeden Callback in Registrierungsreihenfolge auf. Ein Callback, der als Seiteneffekt in eine Session, einen Cache oder ein Log schreibt, macht den Hook nicht-deterministisch. Zwei Plugins könnten um denselben Key konkurrieren. Fix: Halten Sie add-Callbacks rein — sie existieren, um einen Wert beizutragen, nicht um Arbeit zu erledigen. Brauchen Sie einen Seiteneffekt, registrieren Sie einen EVENT-Listener auf einem separaten Hook.
6. Überlappende CSS-Scopes
Zwei Plugins injizieren beide CSS über layout.head.assets; beide definieren eine Klasse namens .mc-popover. Die Reihenfolge, in der Plugins geladen werden, ist die Reihenfolge, in der ihr CSS landet — last wins. Fix: Namespacen Sie Plugin-CSS-Klassen (.acmecorp-loyalty-popover) oder scopen Sie über einen Attribut-Selektor auf einem Wrapper-Element. Der Host kontrolliert Plugin-CSS nicht — das ist Disziplinsache des Plugin-Autors.
Wie es weitergeht
UI-Injection ist die am häufigsten nachgefragte Oberfläche für neue Plugin-Autoren — aber selten das gesamte Feature. Drei Seiten führen denselben Werkzeugkasten weiter: