Monta una chatbox, un gruppo sidebar o una card di pagina — senza forkare un Blade.

L'applicazione host riserva quattro tipi di slot perché i plugin possano renderizzarvi dentro: tre slot a livello di layout che si attivano su ogni pagina che estende i layout master app / admin, più uno slot per-pagina per injection più granulare. Tutti e quattro usano il pattern REGISTRY — ogni plugin che si registra contribuisce con una stringa HTML (o null per saltare), e l'host itera l'array, filtra i return falsy ed emette ogni frammento senza escape. Questa pagina copre il contratto, l'args-bag, le implementazioni canoniche di acelle/ai, e gli anti-pattern che sembrano giusti ma si rompono in produzione.

Perché esiste l'UI injection

Un plugin che aggiunge una feature di solito ha bisogno di farla emergere da qualche parte nella UI dell'host. Le opzioni naïf sono cattive in modi diversi: forkare i layout Blade dell'host costringe il plugin a mantenere una propria copia attraverso ogni upgrade dell'host; patcharli all'install time lascia il sorgente dell'host fuori sync con quanto gira in produzione. Entrambe bloccano il plugin e l'host in un onere di manutenzione che cresce a ogni release.

Il sistema plugin evita quel trade-off riservando slot nominati dentro i layout master dell'host. Ogni slot è un hook REGISTRY — ogni plugin che si registra contribuisce con una stringa HTML, l'host le raccoglie al render time, filtra le contribuzioni falsy ed emette ogni frammento nell'ordine di registrazione. I plugin non vedono mai il sorgente Blade dell'host; l'host non sa mai quali plugin hanno contribuito con cosa.

I tre slot di layout

Tre hook REGISTRY si attivano dai layout master app e admin. Insieme coprono quasi ogni estensione UI di cui un plugin avrà mai bisogno — asset di head-of-document, widget di body-end, e gruppi admin sidebar.

Chiave hookDove vive il call siteArgs bagUsato per
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php, appena prima di @yield('head') [$layout, $context] Tag <link> / <style> / <script> che devono caricarsi prima del contenuto specifico della pagina — CSS chatbox, script popover sparkle
layout.body.before_close Stessi file, appena prima di </body> [$layout, $context] Widget floating che si montano una volta per pagina — bolla chatbox, overlay modali, popover sparkle
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (nessun args) Sezioni admin sidebar contribuite dal plugin — ogni entry renderizza come un frammento <div class="mc-nav-group">...</div>

Tutti e tre vengono raccolti attraverso lo stesso idioma lato host. Lo snippet Blade che è in resources/views/refactor/layouts/admin.blade.php recita:

@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
    {!! $html !!}
@endforeach

Tre cose da leggere in quello snippet: collect prende un args bag (qui ['admin']), array_filter scarta ogni contribuzione null / false / '', e l'HTML sopravvissuto viene emesso con {!! !!} — senza escape, perché è Blade già renderizzato.

Il contratto — restituisci HTML o null

Una callback REGISTRY per uno qualsiasi dei tre slot di layout restituisce una di due cose:

  1. Una stringa HTML — tipicamente il risultato di view('myname::partials.foo')->render(). L'host la emette letteralmente con {!! !!}.
  2. null (o qualsiasi valore falsy — false, '', 0). L'array_filter dell'host la scarta. Questo è il modo convenzionale per gate-are una contribuzione tramite feature flag, status del plugin, ambiente o context per-request.

Restituire null è preferibile rispetto a non registrarsi affatto. Il boot() del plugin gira una volta per processo; la decisione se contribuire dovrebbe avvenire ad ogni render, non al boot. Il check aiPluginAvailable() in storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 è l'esempio canonico — la closure va in short-circuit a null ogni volta che il modulo AI è gated off, lasciando intatta ogni contribuzione di ogni altro plugin.

L'HTML restituito deve essere autocontenuto. L'host droppa il frammento nel documento al call site senza ulteriore escape o wrapping. Tutto ciò da cui il frammento dipende — CSS, JS, file font — deve già essere caricato al momento del rendering. Per questo layout.head.assets esiste oltre a layout.body.before_close: i frammenti head si caricano per primi, quelli body si montano per ultimi, e il plugin può splittare la registrazione asset attraverso entrambi gli slot quando serve.

L'args-bag — $layout e $context

layout.head.assets e layout.body.before_close passano entrambi due args posizionali: $layout (una stringa che identifica quale layout master ha attivato lo slot — 'app', 'admin', ecc.) e $context (un array opzionale che porta prop specifiche della superficie che il layout sceglie di esporre).

// 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();
});

L'unica chiave hook condivisa si attiva da ogni layout master — app, admin, l'email builder, il form builder, l'editor di automation. Il partial del plugin fa dispatch internamente su $layout per renderizzare il giusto set di asset o configurazione chatbox. Non c'è un hook separato layout.app.head.assets / layout.admin.head.assets; il nome del layout è solo un discriminator dentro un'unica bag condivisa.

Aggiungere altri args posizionali a uno slot di layout esistente romperebbe ogni plugin che già binda una closure con l'arità originale. Il nuovo context appartiene all'array $context (che può crescere senza cambiare la signature della closure), oppure dietro a una chiave hook separata. Le contribuzioni aiHooks dell'host stesso gestiscono questo esattamente così — il builder e l'editor di automation passano prop di superficie attraverso $context, e il plugin legge $context['kind'], $context['task'], ecc., quando presenti.

Esempio pratico — la bolla chatbox acelle/ai

Il riferimento canonico per layout injection vive in storage/app/plugins/acelle/ai/src/ServiceProvider.php, righe 678-728. Il plugin contribuisce a tutti e tre gli slot di layout, ognuno dietro lo stesso gate aiPluginAvailable(). Il blocco di registrazione completo, parafrasato secondo il contratto qui sopra:

// 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();
    });
}

Cosa atterra in produzione da quei tre blocchi: ogni pagina che estende il layout app o admin riceve CSS / JS chatbox in <head>, l'HTML della bolla chatbox prima di </body>, e (solo nelle pagine admin) un gruppo sidebar "AI" renderizzato dopo i gruppi built-in dell'host. Nessuno dei file Blade dell'host è stato modificato per far funzionare tutto ciò — il plugin contribuisce attraverso le chiavi hook condivise e l'host renderizza qualsiasi cosa atterri.

Il plugin gate-a la contribuzione con aiPluginAvailable(), che controlla ai_plugin_active() — un helper che alla fine si risolve in Plugin::getByName('acelle/ai')->isActive(). Quando un admin disabilita il plugin dalla pagina admin Plugins, ogni callback restituisce null alla richiesta successiva, e la UI chatbox + sparkle scompare — senza ricaricare route, droppare servizi registrati o invalidare alcuna cache.

admin.sidebar.groups è il più semplice dei tre hook di layout: nessun args, la callback restituisce un frammento autocontenuto <div class="mc-nav-group">...</div>. L'host lo renderizza dopo i gruppi built-in (Customers, Plans, Settings, ...) e prima di qualsiasi closing layout. L'ordine è l'ordine di registrazione, quindi i plugin che vogliono vincere sulla posizione di render dovrebbero registrarsi tardi in boot() dopo le dipendenze.

Il gruppo sidebar acelle/ai vive in resources/views/partials/admin_sidebar_group.blade.php dentro il plugin e renderizza un gruppo "AI" con tre o quattro figli a seconda dei flag di plan. Lo stesso pattern funziona per qualsiasi plugin che ha bisogno di una sezione admin top-level — un plugin di loyalty point, un plugin di payment gateway, un plugin di sending driver regionale.

Slot a livello di pagina — page.{controller}.{action}.{slot}

L'injection a livello di layout copre cosa deve apparire su ogni pagina; gli slot a livello di pagina coprono cosa deve apparire su una specifica. La naming convention rende il binding esplicito:

page.{controller_slug}.{action}.{slot}

Esempi:

  • page.maillist.show.body — card extra renderizzate dentro al body della pagina di dettaglio mail-list
  • page.maillist.verification.body — contenuto renderizzato sopra al blocco verification status
  • page.campaign.index.sidebar — aggiunte sidebar sulla pagina indice campaign
  • page.customer.edit.footer — aggiunte footer sulla pagina edit customer

Il call site dell'host sembra uguale agli slot di layout — collect, array_filter, emetti ogni frammento con {!! !!}:


@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
    {!! $html !!}
@endforeach

E la contribuzione del plugin sembra esattamente come quella di uno slot di layout, con l'args bag slot-specifico passato attraverso:

// 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();
});

Gli args bag per gli slot a livello di pagina tendono a portare il model rilevante — la mail list, la campaign, il customer — così il plugin può leggere qualsiasi cosa gli serva senza una query DB extra. Seguire quella convenzione mantiene la signature dell'hook stabile attraverso evoluzioni del modello host: aggiungere un nuovo campo a MailList non rompe la signature dell'hook di alcun plugin, perché il plugin riceve sempre il model.

Redirect di pagina — la variante FILTER

Una manciata di hook di pagina usano il pattern FILTER invece di REGISTRY — il caso tipico è "lascia che i plugin decidano se l'utente debba essere rediretto prima che il core renderizzi". Il contratto:

// 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"
});

La shape è FILTER (trasformazione concatenata di un valore) anziché REGISTRY (contribuzioni multiple indipendenti) perché solo un redirect può avvenire per request. Restituire l'input non modificato dalla closure è l'opt-out convenzionale — il plugin successivo nella catena vede qualsiasi cosa abbia deciso il precedente. Il primo valore non-null vince perché il controller controlla if ($redirect) dopo che la filter chain finisce.

Questo pattern è quello che athena/evs usa per instradare l'utente verso la propria pagina di verification quando il plugin di email-verification ha bisogno di prendere il controllo della superficie di maillist verification. L'intera meccanica di FILTER vive nel deep-dive del sistema Hook; il takeaway pratico qui è che i redirect a livello di pagina usano FILTER, mentre il rendering a livello di pagina usa REGISTRY.

Pubblicazione asset — bundling di CSS / JS / immagini con il plugin

Gli slot di layout sono dove un plugin renderizza. La pubblicazione asset è cosa l'HTML renderizzato può referenziare. I plugin che shippano CSS, JavaScript, font o immagini usano l'API standard publishes() di Laravel, con il tag 'plugin' che l'host conosce:

// In ServiceProvider::boot()
$this->publishes([
    __DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');

A ogni Plugin::register(), l'host esegue artisan vendor:publish --tag=plugin --force, che copia l'albero resources/assets/ del plugin in public/plugins/{vendor}/{name}/. I partial stessi del plugin referenziano gli asset attraverso quel percorso:


<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">

La route che serve l'icona da routes.php (la named route plugin.{vendor}.{name}.icon) è l'alternativa — invece di pubblicare un SVG statico in public/, il plugin può esporre una route HTTP che fa stream della sua icona direttamente da storage/app/plugins/{vendor}/{name}/icon.svg. Il trade-off: il percorso pubblicato è più veloce (CDN-cacheable) ma richiede una publish a ogni install; il percorso via route è autocontenuto ma paga un boot Laravel per request.

Anti-pattern

1. Restituire un oggetto Blade View invece di una stringa

L'host emette qualsiasi cosa la closure restituisca con {!! !!}. Restituire view('foo') emette il __toString dell'oggetto, che funziona la maggior parte delle volte ma perde la chance per la closure di gestire gli errori di render in modo grazioso. Fix: chiama sempre ->render() e restituisci la stringa risultante, esattamente come fanno le registrazioni acelle/ai.

2. Dimenticare il branch di gating null

Una callback REGISTRY che restituisce sempre HTML continua a contribuire indipendentemente dal fatto che il plugin sia attivo o no — perché autoloadWithoutDbQuery() carica anche i plugin inattivi (vedi Architettura del plugin § Perché i plugin inattivi influenzano comunque l'app). Fix: guardia con Plugin::enabled('myvendor/myplugin') o un helper di feature-flag all'inizio di ogni closure, restituisci null quando gated off.

3. Chiamare collect() con args extra che l'host non ha passato

I plugin non chiamano Hook::collect da soli — lo fa l'host. Se un plugin ha bisogno del nome del layout per una decisione custom, lo legge dal primo arg della closure. Provare a fare Hook::collect su uno slot di layout da dentro un plugin esegue una volta in più la callback di ogni altro plugin. Fix: la closure riceve tutti gli args che le servono; non re-invocare mai collect da dentro un handler registrato.

4. Renderizzare lavoro bloccante dentro la closure

La closure gira una volta per render di pagina — una chiamata Stripe API o una query DB da 200ms al suo interno aggiunge quel costo a ogni request. Fix: precomputa, cacha, o sposta a un loader async. Il frammento può restituire un placeholder <div data-async-loader> che il JS del plugin idrata da una fetch in background.

5. Side effect in una callback REGISTRY

collect chiama ogni callback nell'ordine di registrazione. Una callback che scrive su una session, cache o log come side effect rende l'hook non deterministico. Due plugin potrebbero gareggiare per mutare la stessa chiave. Fix: mantieni pure le callback add — esistono per contribuire un valore, non per fare lavoro. Se ti serve un side effect, registra un listener EVENT su un hook separato.

6. Scope CSS sovrapposti

Due plugin iniettano entrambi CSS attraverso layout.head.assets; entrambi definiscono una classe chiamata .mc-popover. L'ordine in cui i plugin vengono caricati è l'ordine in cui il loro CSS atterra, e l'ultimo vince. Fix: namespace-a le classi CSS del plugin (.acmecorp-loyalty-popover), o scope-a con un attribute selector su un elemento wrapper. L'host non vigila sul CSS del plugin — quella è disciplina dell'autore del plugin.

Dove andare poi

L'UI injection è la superficie più richiesta per nuovi autori di plugin, ma raramente è l'intera feature. Tre pagine portano lo stesso toolkit oltre: