Quatre patterns. Un fichier. Toute la surface d'extensibilité.

Chaque manière qu'a un plugin de participer au core passe par App\Library\HookManager. La classe fait environ 160 lignes, n'a pas de dépendances, et expose exactement quatre paires register/execute : add/collect (REGISTRY), on/fire (EVENT), set/perform (BEHAVIOR), et modify/filter (FILTER). Cette page couvre ce que chaque pattern garantit, quand l'utiliser, à quoi ressemble un conflit entre deux plugins, et les anti-patterns qui semblent corrects mais cassent en production. Chaque exemple est ancré dans un site d'appel livré dans l'application hôte.

La carte des quatre patterns

Chaque hook de la base de code rentre dans exactement une des quatre formes. La forme détermine la sémantique de conflit, le traitement des valeurs de retour, et le type d'interaction qui a du sens entre core et plugins. Recourir au mauvais pattern apparaît plus tard sous forme de cas limite étrange — savoir lequel est lequel d'emblée évite de réécrire l'intégration.

PatternEnregistrerExécuterRetourneConflit
REGISTRYHook::add($name, $cb)Hook::collect($name)tableau de la valeur de retour de chaque callbackMerge — chaque callback s'exécute, chaque résultat est conservé
EVENTHook::on($name, $cb)Hook::fire($name, [...args])rien — les valeurs de retour sont jetéesTous déclenchés — chaque écouteur s'exécute, les effets de bord se composent
BEHAVIORHook::set($name, $cb) ou setIfEmptyHook::perform($name, [...args])ce que renvoie l'unique callback enregistréExclusif — un second set sur le même nom lève immédiatement
FILTERHook::modify($name, $cb)Hook::filter($name, $value)la valeur, transformée à travers chaque callback dans l'ordre d'enregistrementChaîne — chaque callback reçoit la sortie du précédent

Un modèle mental utile : REGISTRY répond à « que voulez-vous contribuer » ; EVENT répond à « vouliez-vous savoir » ; BEHAVIOR répond à « comment dois-je faire ceci » ; FILTER répond à « en quoi cela doit-il se transformer ». Les quatre sections suivantes couvrent chacun en profondeur, avec les vrais sites d'appel livrés dans le core.

REGISTRY — add() + collect()

Un plugin contribue un ou plusieurs éléments à une liste nommée. L'hôte appelle collect() pour obtenir un tableau de chaque contribution. Chaque callback s'exécute, chaque valeur de retour est capturée, dans l'ordre d'enregistrement.

Mécanique

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

Vrais hooks REGISTRY dans le core

Clé de hookOù l'hôte le collecteCe que les plugins contribuent
register_sending_server_driverapp/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107Métadonnée de classe de driver — type, driver (FQCN), libellé
register_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:120Métadonnée de formulaire « Add server » select — icône, nom, description, create_url
register_vendor_config_keysapp/Model/SendingServer.php:206Noms de champs de formulaire de config par driver — ['my_api_key', 'my_region']
add_translation_fileapp/Model/Language.php:532 + AppServiceProvider::boot()Descripteur de fichier de traduction — dossier de locale, préfixe, fichier master
captcha_methodapp/Model/Setting.php:290Métadonnée de fournisseur de captcha — id, libellé, closure de rendu
list_import_notificationsapp/Http/Controllers/SubscriberController.php:402Fragments HTML de bannière affichés au-dessus du dialogue d'import de liste
generate_big_notice_for_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:235Bannières HTML pour la page de détail de serveur d'envoi
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.phpChaînes CSS / JS injectées avant @yield('head')
layout.body.before_closeSame files, before </body>HTML de widget flottant — bulles de chatbox, modales, popovers sparkle
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSections de sidebar admin contribuées par des plugins

Quand utiliser REGISTRY

  • Plusieurs plugins pourraient contribuer (drivers d'envoi, méthodes de captcha, entrées de sidebar).
  • L'hôte veut chaque contribution, pas seulement la dernière.
  • Les contributions se composent sans conflit — éléments de liste, entrées de menu, fragments de bannière, métadonnées de configuration.

Convention de nommage

Au sein de la base de code, les noms REGISTRY tendent à se lire comme une locution verbale décrivant la contribution : register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Les noms à verbe singulier (register_*, add_*) signalent « contribuez l'un de ceux-ci », mais la mécanique collect() rassemble quand même chaque contribution — il n'y a rien côté enregistrement qui limite un plugin à une entrée unique.

EVENT — on() + fire()

Un plugin réagit lorsque quelque chose se produit dans l'hôte. Les valeurs de retour sont jetées — le contrat est unidirectionnel : le core notifie, les plugins composent des effets de bord. Chaque écouteur s'exécute, dans l'ordre d'enregistrement.

Mécanique

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

Vrais hooks EVENT déclenchés par le core

Nom de hookOù il se déclencheArgs-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_terminatedCâblé via 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(aucun arg)
delete_plugin_{vendor}/{name}app/Model/Plugin.php:673[$keepData] — vaut false par défaut

Quand utiliser EVENT

  • Le plugin veut réagir mais n'a pas besoin d'influencer l'issue du core.
  • Effets de bord uniquement — envoyer des webhooks, écrire des logs, attribuer des points de fidélité, dispatcher des jobs de queue.
  • Le core s'est déjà engagé sur l'action au moment où l'événement se déclenche ; l'écouteur ne peut pas l'annuler.

EVENT et le flag $keepData. L'événement delete_plugin_* est le seul endroit où l'args-bag porte un signal hors bande. $keepData = true dit à l'écouteur « l'admin veut conserver les données de ce plugin, sautez migrate:rollback ». L'écouteur du squelette le respecte déjà : function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Les plugins ne possédant aucune donnée préservable peuvent ignorer l'arg avec la valeur par défaut.

BEHAVIOR — set() + perform()

Un seul callable possède le comportement nommé. L'hôte appelle perform() pour exécuter quiconque est actuellement enregistré. set() revendique le nom exclusivement — si un second appelant essaie de set le même nom, HookManager::set() lève une exception immédiatement. Pas d'écrasement silencieux ; les conflits font surface au boot, pas en production.

Mécanique

// 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 règle de timing de setIfEmpty

La valeur par défaut de l'hôte passe par setIfEmpty(), pas par set() — la différence est intentionnelle. setIfEmpty n'enregistre le callback que si personne d'autre n'a revendiqué le nom auparavant ; si un plugin a déjà appelé set dans son boot(), la valeur par défaut de l'hôte est silencieusement ignorée.

Cela signifie que setIfEmpty doit être placé juste avant perform(), dans le contrôleur ou le modèle, pas dans un ServiceProvider. La raison : au moment où le handler de requête du contrôleur s'exécute, chaque ServiceProvider::boot() de plugin est terminé — donc toute surcharge de plugin enregistrée via set a déjà pris effet. Placer la valeur par défaut dans register() ou boot() risquerait de s'exécuter avant le set() du plugin et de le bloquer.

Vrais hooks BEHAVIOR dans le core

Nom de hookOù l'hôte enregistre la valeur par défaut + performCe qui est surchargé
dispatch_list_import_jobapp/Http/Controllers/SubscriberController.php:433-438La classe de job mise en queue quand un admin importe un CSV — les plugins peuvent y substituer une variante plus rapide, distribuée ou instrumentée
icon_url_{vendor}/{name}app/Model/Plugin.php:632-637 (per-plugin)L'URL d'image rendue pour l'entrée de plugin sur la page admin Plugins — vaut /images/plugin.svg par défaut ; les plugins appellent Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) dans leur boot()

Quand utiliser BEHAVIOR

  • Exactement un morceau de logique doit s'exécuter.
  • Une valeur par défaut raisonnable existe, mais un plugin doit pouvoir la remplacer complètement.
  • Deux plugins revendiquant le même comportement est un bug, pas une fonctionnalité — vous voulez que cela échoue bruyamment.

L'exclusivité BEHAVIOR est une fonctionnalité, pas un problème. Si deux plugins ont légitimement besoin d'influencer le même comportement, la bonne forme est REGISTRY (chacun contribue un candidat, l'hôte en choisit un) ou FILTER (chaque plugin transforme la valeur à travers une chaîne). Recourir à BEHAVIOR pour de la « logique partagée » force une course ingagnable entre deux auteurs de plugins. HookManager::set() lève avec Behavior "{name}" has already been registered au moment où le second plugin démarre — donc le conflit est impossible à livrer en production.

FILTER — modify() + filter()

Une valeur traverse une chaîne de callbacks. Chaque callback reçoit la valeur courante (plus tout arg positionnel supplémentaire) et renvoie la valeur à passer au suivant. L'hôte appelle <code>filter()</code> avec la valeur initiale ; le retour est la valeur finale après que chaque plugin a eu son tour.

Mécanique

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

Args positionnels optionnels

Hook::filter($name, $value, $extraParams) accepte un troisième tableau d'arguments positionnels qui sont passés à chaque callback à côté de la valeur courante. L'exemple de contrat du guide plugin est le redirect de la 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

Quand utiliser FILTER

  • Une valeur voyage à travers plusieurs plugins avant que l'hôte ne l'utilise.
  • Chaque plugin peut composer avec le précédent — ajouter à un menu, modifier le contenu d'email, conditionner un redirect, transformer un payload.
  • Renvoyer l'input inchangé est l'opt-out conventionnel — pas d'exception, pas de signal spécial.

FILTER est le pattern le moins exercé dans la base de code core actuelle. L'implémentation HookManager est stable (lignes 143-159 du fichier), et le contrat est documenté pour les auteurs de plugins, mais le core n'appelle pas encore Hook::filter dans les chemins chauds en production au-delà de l'exemple documenté page.maillist.show.redirect. Les nouveaux chemins chauds côté hôte qui veulent des transformations composables par les plugins devraient recourir à FILTER plutôt qu'à BEHAVIOR — la sémantique de chaîne est exactement ce dont la plupart des situations « je veux que les plugins puissent ajouter à ceci » ont besoin.

Sémantique de conflit à travers les quatre patterns

Quand deux plugins ciblent le même nom de hook, les quatre patterns réagissent différemment. Savoir quel type de pattern vous avez vous dit exactement à quoi ressemble le conflit, et s'il fera surface au boot ou bien plus tard.

PatternCe que font deux plugins ciblant le même nomComment cela fait surface
REGISTRYLes deux contributions sont conservées ; collect renvoie les deuxPas de conflit — par conception. L'ordre est l'ordre d'enregistrement.
EVENTLes deux écouteurs s'exécutent ; les effets de bord se composentPas de conflit — par conception. L'ordre est l'ordre d'enregistrement.
BEHAVIORLe second appel set lève avec Behavior "{name}" has already been registeredException au boot — le ServiceProvider::boot() du plugin échoue, le fichier master enregistre l'erreur, la page admin Plugins affiche une pastille rouge. La production ne voit jamais l'écrasement silencieux.
FILTERLa valeur passe à travers les deux callbacks dans l'ordre d'enregistrementPas de conflit — par conception. Chaque callback peut se retirer en renvoyant l'input inchangé.

Trois des quatre patterns sont sans conflit parce qu'ils agrègent. BEHAVIOR est le seul avec une propriété exclusive, et le mode d'échec est une exception dure au boot — un choix de conception délibéré pour qu'un mésusage ne puisse pas coexister avec une install fonctionnelle.

La convention args-bag

Chaque fire / collect / perform / filter prend la même forme : $name, $value (or default), $params. Le tableau $params est déballé positionnellement dans le callback enregistré. Chaque callback de la chaîne reçoit les mêmes args.

  • Gardez les args-bags petits et stables. Une fois qu'un hook est livré, les args deviennent un contrat — ajouter un arg positionnel casse chaque plugin qui a déjà lié la closure avec une signature fixe.
  • Passez des modèles, pas des sacs de champs. fire('customer_added', [$customer]) laisse l'écouteur décider quels champs lire ; fire('customer_added', [$customer->email, $customer->uid, ...]) verrouillerait les args dans ce qui étaient les champs au moment où le hook a été livré.
  • Utilisez des hooks supplémentaires plutôt que de surcharger les args. Si un slot a besoin d'un contexte plus riche, enregistrez une clé de hook séparée plutôt que d'ajouter un cinquième arg positionnel optionnel.

La bizarrerie collect par référence

Une petite portion du core utilise collect() avec des arguments par référence comme hook de mutation — hors du modèle canonique à quatre patterns. Le site d'appel filter_aws_ses_dns_records en est l'exemple canonique :

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

L'hôte déclenche le hook en s'attendant à ce que les plugins mutent $dkims et $spf sur place. Stricto sensu, c'est un mésusage de REGISTRY — une vraie chaîne FILTER aurait été la bonne forme. Le comportement fonctionne parce que les closures PHP honorent les args par référence, mais les auteurs de plugins ne devraient pas écrire de nouveaux sites d'appel dans ce style. Recourez à FILTER (valeur unique transformée) ou REGISTRY (contributions multiples immuables) à la place.

Six anti-patterns à éviter

Les patterns ci-dessous semblent tous corrects à première vue et cassent de manière subtile. Chacun est ancré dans une classe de bug déjà vue dans des plugins en production.

1. Enregistrer des hooks dans register() alors qu'ils ont besoin de boot()

Le register() de l'hôte s'exécute avant tout boot() de ServiceProvider. Les hooks enregistrés là se déclenchent avant que les dépendances câblées par les register() d'autres providers ne soient en place. Symptôme : Class not found dès la toute première requête, avant que tout code de plugin n'exécute son chemin principal. Correctif : seul le hook add_translation_file appartient à register() ; tout le reste va dans boot().

2. Recourir à BEHAVIOR quand « partagé » est l'objectif

BEHAVIOR lève sur un second set. Si deux plugins ont légitimement besoin d'influencer le même point, FILTER (composer) ou REGISTRY (collecter) sont la bonne forme. Correctif : réécrivez le contrat de hook — émettez une chaîne ou une liste, pas un callable exclusif.

3. Mettre setIfEmpty dans un ServiceProvider

setIfEmpty ne prend effet que si personne d'autre n'a encore enregistré. Le placer dans register() ou boot() signifie qu'il pourrait s'exécuter avant le set() d'un plugin, verrouillant le plugin dehors. Correctif : placez setIfEmpty directement au-dessus de l'appel perform correspondant, dans le contrôleur ou le modèle, pour que chaque boot() de plugin soit déjà terminé.

4. Muter un état partagé à l'intérieur d'un callback REGISTRY

collect appelle chaque callback dans l'ordre d'enregistrement. Un callback qui écrit dans un cache ou une session partagés en effet de bord rend le hook non-déterministe — l'exécuter deux fois avec un ordre de plugin différent donne un état de cache différent. Correctif : gardez les callbacks add purs. Si la contribution dépend d'effets de bord, enregistrez un écouteur EVENT séparément.

5. Ajouter des args positionnels à un hook existant

Une fois qu'un hook est en production, des plugins ont déjà lié des closures avec l'arité d'origine. Ajouter un cinquième arg positionnel casse chaque binding qui l'a omis. Correctif : enregistrez un nouveau nom de hook (customer_added_v2) et acceptez une fenêtre de transition, ou passez le nouveau contexte via l'objet modèle qui est déjà dans l'args-bag.

6. Utiliser collect() pour une réponse unique

REGISTRY renvoie un tableau — même quand un seul plugin enregistre, l'hôte obtient [$result]. Traiter cela comme la réponse ($first = Hook::collect(...)[0]) prend silencieusement quel que soit le plugin qui a démarré en premier, sans sémantique de conflit. Correctif : utilisez BEHAVIOR si exactement une réponse est attendue.

Comment choisir un pattern

La décision se réduit généralement à quatre questions. Y répondre dans l'ordre choisit la bonne forme :

  1. Plusieurs plugins doivent-ils composer ? Si oui, REGISTRY (contributions indépendantes) ou FILTER (transformation chaînée). Si non, BEHAVIOR (surcharge exclusive).
  2. L'hôte a-t-il besoin d'une valeur de retour ? Si non, EVENT (effets de bord uniquement). Si oui, REGISTRY / BEHAVIOR / FILTER.
  3. La valeur passe-t-elle entre plusieurs mains ? Si oui, FILTER (chaîne). Si chaque main contribue indépendamment, REGISTRY (collecter).
  4. Un conflit entre deux plugins est-il un bug ? Si oui, BEHAVIOR (échec bruyant au boot). Si non, les patterns sans conflit.

Dans le doute, choisissez le pattern le plus lâche. REGISTRY plus un EVENT « hors bande » pour le nettoyage est presque toujours plus flexible que BEHAVIOR — la sémantique de conflit est ce qui tue BEHAVIOR en pratique, et les patterns lâches laissent les plugins composer sans coordination.

Où aller ensuite

Vous avez désormais les quatre patterns en profondeur et la sémantique de conflit qui rend chaque forme prévisible. Trois pages transforment ce savoir en surfaces d'usage quotidien qu'un vrai plugin touchera effectivement :