Quattro pattern. Un solo file. L'intera superficie di estensibilità.

Ogni modo in cui un plugin partecipa al core passa per App\Library\HookManager. La classe ha circa 160 righe, zero dipendenze, ed espone esattamente quattro coppie register/execute: add/collect (REGISTRY), on/fire (EVENT), set/perform (BEHAVIOR) e modify/filter (FILTER). Questa pagina copre cosa garantisce ogni pattern, quando usarlo, come appare un conflitto tra due plugin e gli anti-pattern che sembrano giusti ma si rompono in produzione. Ogni esempio è ancorato a un call site che sta nell'applicazione host.

La mappa dei quattro pattern

Ogni hook nella codebase ricade in esattamente una di quattro forme. La forma determina la semantica dei conflitti, la gestione dei valori di ritorno e che tipo di interazione tra core e plugin abbia senso. Scegliere il pattern sbagliato emerge dopo come uno strano edge case — sapere quale è quale fin dall'inizio risparmia di riscrivere l'integrazione.

PatternRegisterExecuteRestituisceConflitto
REGISTRYHook::add($name, $cb)Hook::collect($name)array del return value di ogni callbackMerge — ogni callback parte, ogni risultato resta
EVENTHook::on($name, $cb)Hook::fire($name, [...args])niente — i valori di ritorno vengono scartatiAll-fire — ogni listener parte, gli effetti collaterali si compongono
BEHAVIORHook::set($name, $cb) o setIfEmptyHook::perform($name, [...args])qualsiasi cosa restituisca l'unico callback registratoEsclusivo — un secondo set sullo stesso name lancia immediatamente
FILTERHook::modify($name, $cb)Hook::filter($name, $value)il valore, trasformato da ogni callback nell'ordine di registrazioneChain — ogni callback riceve l'output del precedente

Un modello mentale utile: REGISTRY risponde a "cosa vuoi contribuire"; EVENT risponde a "volevi saperlo"; BEHAVIOR risponde a "come devo farlo"; FILTER risponde a "in cosa deve diventare". Le prossime quattro sezioni coprono ognuno in profondità, con i call site reali presenti nel core.

REGISTRY — add() + collect()

Un plugin contribuisce uno o più item a una lista nominata. L'host chiama collect() per ottenere un array di ogni contributo. Ogni callback parte, ogni return value viene catturato, in ordine di registrazione.

Meccanica

// 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'];
}

Hook REGISTRY reali nel core

Hook keyDove l'host la raccoglieCosa contribuiscono i plugin
register_sending_server_driverapp/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107Metadata della classe driver — type, driver (FQCN), label
register_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:120Metadata della select-form "Add server" — icona, name, description, create_url
register_vendor_config_keysapp/Model/SendingServer.php:206Nomi dei campi del form di config per-driver — ['my_api_key', 'my_region']
add_translation_fileapp/Model/Language.php:532 + AppServiceProvider::boot()Descrittore del file di traduzione — locale folder, prefisso, master file
captcha_methodapp/Model/Setting.php:290Metadata del provider captcha — id, label, closure di render
list_import_notificationsapp/Http/Controllers/SubscriberController.php:402Frammenti HTML di banner mostrati sopra il dialog di list-import
generate_big_notice_for_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:235Banner HTML per la pagina dettaglio sending-server
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.phpStringhe CSS / JS iniettate prima di @yield('head')
layout.body.before_closeSame files, before </body>HTML di widget floating — bubble chatbox, modal, popover sparkle
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSezioni della sidebar admin contribuite dai plugin

Quando usare REGISTRY

  • Più plugin potrebbero contribuire (sending driver, metodi captcha, item della sidebar).
  • L'host vuole ogni contributo, non solo l'ultimo.
  • I contributi si compongono senza conflitto — item di lista, voci di menu, frammenti di banner, metadata di configurazione.

Convenzione di naming

Dentro la codebase, i name REGISTRY tendono a leggersi come una frase verbale che descrive il contributo: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. I name con un verbo singolare (register_*, add_*) segnalano "contribuisci uno di questi", ma la meccanica di collect() raccoglie comunque ogni contributo — non c'è nulla sul lato registrazione che limiti un plugin a una singola entry.

EVENT — on() + fire()

Un plugin reagisce quando succede qualcosa nell'host. I valori di ritorno vengono scartati — il contratto è a senso unico: il core notifica, i plugin compongono effetti collaterali. Ogni listener parte, in ordine di registrazione.

Meccanica

// 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]);

Hook EVENT reali lanciati dal core

Hook nameDove parteArgs bag
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_terminatedWired tramite 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(no args)
delete_plugin_{vendor}/{name}app/Model/Plugin.php:673[$keepData] — default a false

Quando usare EVENT

  • Il plugin vuole reagire ma non ha bisogno di influenzare l'esito del core.
  • Solo effetti collaterali — inviare webhook, scrivere log, assegnare punti loyalty, fare dispatch di queue job.
  • Il core si è già impegnato sull'azione nel momento in cui l'evento parte; il listener non può annullarla.

EVENT e il flag $keepData. L'evento delete_plugin_* è l'unico posto dove l'args bag porta un segnale out-of-band. $keepData = true dice al listener "l'admin vuole tenere i dati di questo plugin, salta il migrate:rollback". Il listener dello scheletro lo rispetta già: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. I plugin che non possiedono dati preservabili possono ignorare l'arg con il default.

BEHAVIOR — set() + perform()

Un singolo callable possiede il comportamento nominato. L'host chiama perform() per eseguire chiunque sia attualmente registrato. set() rivendica il name in modo esclusivo — se un secondo caller prova a fare set sullo stesso name, HookManager::set() lancia un'eccezione immediatamente. Niente override silenziosi; i conflitti emergono al boot, non in produzione.

Meccanica

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

La regola di timing di setIfEmpty

Il default dell'host passa per setIfEmpty(), non per set() — la differenza è intenzionale. setIfEmpty registra il callback solo se nessun altro ha già rivendicato il name; se un plugin ha già chiamato set nel suo boot(), il default dell'host viene saltato silenziosamente.

Questo significa che setIfEmpty deve essere piazzato direttamente prima di perform(), nel controller o nel model, non in un service provider. Il motivo: nel momento in cui il request handler del controller gira, il ServiceProvider::boot() di ogni plugin è terminato — quindi qualsiasi override registrato da un plugin tramite set ha già avuto effetto. Mettere il default in register() o boot() rischierebbe di girare prima del set() del plugin, escludendolo.

Hook BEHAVIOR reali nel core

Hook nameDove l'host registra default + performCosa viene sovrascritto
dispatch_list_import_jobapp/Http/Controllers/SubscriberController.php:433-438La classe job messa in coda quando un admin importa un CSV — i plugin possono sostituirla con una variante più veloce, distribuita o instrumentata
icon_url_{vendor}/{name}app/Model/Plugin.php:632-637 (per-plugin)L'URL dell'immagine renderizzata per la entry del plugin sulla pagina admin Plugins — default a /images/plugin.svg; i plugin chiamano Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) nel loro boot()

Quando usare BEHAVIOR

  • Deve girare esattamente un pezzo di logica.
  • Esiste un default ragionevole, ma un plugin deve poterlo sostituire completamente.
  • Due plugin che rivendicano lo stesso comportamento sono un bug, non una feature — vuoi che fallisca rumorosamente.

L'esclusività di BEHAVIOR è una feature, non un problema. Se due plugin hanno legittimamente bisogno di influenzare lo stesso comportamento, la forma giusta è REGISTRY (ciascuno contribuisce un candidato, l'host ne sceglie uno) o FILTER (ogni plugin trasforma il valore attraverso una catena). Ricorrere a BEHAVIOR per "logica condivisa" forza una gara invincibile tra due autori di plugin. HookManager::set() lancia con Behavior "{name}" has already been registered nel momento in cui il secondo plugin fa il boot — quindi il conflitto è impossibile da rilasciare in produzione.

FILTER — modify() + filter()

Un valore passa attraverso una catena di callback. Ogni callback riceve il valore corrente (più eventuali argomenti posizionali extra) e restituisce il valore da passare al successivo. L'host chiama <code>filter()</code> con il valore iniziale; il return è il valore finale dopo che ogni plugin ha avuto il suo turno.

Meccanica

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

Argomenti posizionali opzionali

Hook::filter($name, $value, $extraParams) accetta un terzo array di argomenti posizionali che vengono passati a ogni callback insieme al valore corrente. L'esempio di contratto dalla guida plugin è il redirect maillist:

// 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

Quando usare FILTER

  • Un valore viaggia attraverso più plugin prima che l'host lo usi.
  • Ogni plugin può comporsi col precedente — aggiungere a un menu, modificare contenuto email, condizionare un redirect, trasformare un payload.
  • Restituire l'input invariato è l'opt-out convenzionale — niente eccezione, nessun segnale speciale.

FILTER è il pattern meno esercitato nella codebase core attuale. L'implementazione di HookManager è stabile (righe 143-159 del file), e il contratto è documentato per gli autori di plugin, ma il core non chiama ancora Hook::filter in hot path di produzione oltre l'esempio documentato page.maillist.show.redirect. Nuovi hot path host-side che vogliono trasformazioni plugin-componibili dovrebbero ricorrere a FILTER più che a BEHAVIOR — la semantica chain è esattamente ciò di cui ha bisogno la maggior parte delle situazioni "voglio che i plugin possano aggiungere a questo".

Semantica dei conflitti tra i quattro pattern

Quando due plugin puntano allo stesso hook name, i quattro pattern reagiscono in modo diverso. Sapere che tipo di pattern hai ti dice esattamente come appare il conflitto e se emergerà al boot o molto più tardi.

PatternCosa fanno due plugin che puntano allo stesso nameCome emerge
REGISTRYEntrambi i contributi vengono mantenuti; collect li restituisce entrambiNessun conflitto — by design. L'ordine è l'ordine di registrazione.
EVENTEntrambi i listener partono; gli effetti collaterali si compongonoNessun conflitto — by design. L'ordine è l'ordine di registrazione.
BEHAVIORLa seconda chiamata set lancia con Behavior "{name}" has already been registeredEccezione al boot — il ServiceProvider::boot() del plugin fallisce, il master file registra l'errore, la pagina admin Plugins mostra una pillola rossa. La produzione non vede mai l'override silenzioso.
FILTERIl valore passa attraverso entrambi i callback nell'ordine di registrazioneNessun conflitto — by design. Ogni callback può fare opt-out restituendo l'input invariato.

Tre dei quattro pattern sono conflict-free perché aggregano. BEHAVIOR è l'unico con ownership esclusiva, e il failure mode è un'eccezione hard al boot — una scelta di design deliberata così che un misuse non possa coesistere con un install funzionante.

La convenzione dell'args-bag

Ogni fire / collect / perform / filter ha la stessa forma: $name, $value (o default), $params. L'array $params viene spacchettato posizionalmente nel callback registrato. Ogni callback nella catena riceve gli stessi args.

  • Tieni gli args bag piccoli e stabili. Una volta che un hook è in rilascio, gli args diventano un contratto — aggiungere un arg posizionale rompe ogni plugin che già bind-a la closure con una signature fissa.
  • Passa model, non bag di campi. fire('customer_added', [$customer]) lascia il listener decidere quali campi leggere; fire('customer_added', [$customer->email, $customer->uid, ...]) bloccherebbe gli args sui campi che esistevano al momento del rilascio dell'hook.
  • Usa hook aggiuntivi invece di sovraccaricare gli args. Se uno slot ha bisogno di contesto più ricco, registra una nuova hook key invece di aggiungere un quinto arg posizionale opzionale.

La stranezza del collect by-reference

Una piccola fetta del core usa collect() con argomenti by-reference come hook di mutazione — fuori dal modello canonico a quattro pattern. Il call site filter_aws_ses_dns_records è l'esempio canonico:

// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);

L'host lancia l'hook aspettandosi che i plugin mutino $dkims e $spf in place. A rigore questo è un misuse di REGISTRY — una vera catena FILTER sarebbe stata la forma giusta. Il comportamento funziona perché le closure PHP onorano gli args by-reference, ma gli autori di plugin non dovrebbero scrivere nuovi call site in questo stile. Ricorri a FILTER (singolo valore trasformato) o REGISTRY (più contributi immutabili).

Sei anti-pattern da evitare

I pattern qui sotto sembrano tutti giusti a prima vista e si rompono in modi sottili. Ognuno è ancorato a una classe di bug già vista in plugin di produzione.

1. Registrare hook in register() quando hanno bisogno di boot()

Il register() dell'host gira prima del boot() di qualsiasi service provider. Gli hook registrati lì partono prima delle dipendenze wired dal register() di altri provider. Sintomo: Class not found alla prima richiesta, prima che qualsiasi codice plugin esegua il suo percorso principale. Fix: solo l'hook add_translation_file va in register(); tutto il resto in boot().

2. Ricorrere a BEHAVIOR quando l'obiettivo è "condiviso"

BEHAVIOR lancia su un secondo set. Se due plugin hanno legittimamente bisogno di influenzare lo stesso punto, FILTER (componi) o REGISTRY (collect) sono la forma giusta. Fix: riscrivi il contratto dell'hook — emetti una catena o una lista, non un callable esclusivo.

3. Mettere setIfEmpty in un service provider

setIfEmpty ha effetto solo se nessun altro ha già registrato. Metterlo in register() o boot() significa che potrebbe girare prima del set() di un plugin, escludendolo. Fix: metti setIfEmpty direttamente sopra la chiamata perform corrispondente, nel controller o nel model, così il boot() di ogni plugin è già terminato.

4. Mutare stato condiviso dentro un callback REGISTRY

collect chiama ogni callback in ordine di registrazione. Un callback che scrive su una cache o session condivisa come effetto collaterale rende l'hook non deterministico — eseguirlo due volte con un ordine plugin diverso dà uno stato cache diverso. Fix: mantieni i callback add puri. Se il contributo dipende da effetti collaterali, registra un listener EVENT separatamente.

5. Aggiungere args posizionali a un hook esistente

Una volta che un hook è in produzione, i plugin hanno già bind-ato closure con l'arity originale. Aggiungere un quinto arg posizionale rompe ogni binding che l'ha omesso. Fix: registra un nuovo hook name (customer_added_v2) e accetta una finestra di transizione, oppure passa il nuovo contesto tramite il model object che è già nell'args bag.

6. Usare collect() per una singola risposta

REGISTRY restituisce un array — anche quando si registra un solo plugin, l'host riceve [$result]. Trattarlo come la risposta ($first = Hook::collect(...)[0]) sceglie silenziosamente qualsiasi plugin abbia fatto il boot per primo, senza semantica di conflitto. Fix: usa BEHAVIOR se ti aspetti esattamente una risposta.

Come scegliere un pattern

La decisione di solito collassa in quattro domande. Rispondervi in ordine sceglie la forma giusta:

  1. Più plugin devono comporsi? Se sì, REGISTRY (contributi indipendenti) o FILTER (trasformazione concatenata). Se no, BEHAVIOR (override esclusivo).
  2. L'host ha bisogno di un return value? Se no, EVENT (solo effetti collaterali). Se sì, REGISTRY / BEHAVIOR / FILTER.
  3. Il valore passa per diverse mani? Se sì, FILTER (catena). Se ogni mano contribuisce indipendentemente, REGISTRY (collect).
  4. Un conflitto tra due plugin è un bug? Se sì, BEHAVIOR (fallimento rumoroso al boot). Se no, i pattern conflict-free.

Nel dubbio, scegli il pattern più lasco. REGISTRY più un EVENT "out of band" per il cleanup è quasi sempre più flessibile di BEHAVIOR — la semantica dei conflitti è ciò che uccide BEHAVIOR nella pratica, e i pattern laschi lasciano comporre i plugin senza coordinazione.

Dove andare ora

Ora hai i quattro pattern in profondità e la semantica dei conflitti che rende prevedibile ogni forma. Tre pagine trasformano questa conoscenza nelle superfici daily-use che un plugin reale toccherà davvero: