Montez un chatbox, un groupe de sidebar ou une carte de page — sans forker un Blade.

L'application hôte réserve quatre types de slot dans lesquels les plugins peuvent rendre : trois slots au niveau layout qui se déclenchent sur chaque page qui étend les layouts master app / admin, plus un slot par page pour une injection plus fine. Les quatre utilisent le pattern REGISTRY — chaque plugin enregistré contribue une chaîne HTML (ou null pour passer), et l'hôte itère le tableau, filtre les retours falsy et émet chaque fragment sans échappement. Cette page couvre le contrat, l'args-bag, les implémentations canoniques acelle/ai et les anti-patterns qui semblent corrects mais cassent en production.

Pourquoi l'injection d'UI existe

Un plugin qui ajoute une fonctionnalité a généralement besoin de faire émerger cette fonctionnalité quelque part dans l'UI hôte. Les options naïves sont mauvaises de différentes façons : forker les layouts Blade de l'hôte oblige le plugin à maintenir sa propre copie à travers chaque mise à jour de l'hôte ; les patcher à l'installation laisse le source de l'hôte désynchronisé avec ce qui tourne en production. Les deux enferment le plugin et l'hôte dans une charge de maintenance qui grandit à chaque release.

Le système de plugins évite ce compromis en réservant des slots nommés à l'intérieur des layouts master de l'hôte. Chaque slot est un hook REGISTRY — chaque plugin enregistré contribue une chaîne HTML, l'hôte les collecte au moment du rendu, filtre les contributions falsy, et émet chaque fragment dans l'ordre d'enregistrement. Les plugins ne voient jamais le source Blade de l'hôte ; l'hôte ne sait jamais quels plugins ont contribué quoi.

Les trois slots de layout

Trois hooks REGISTRY se déclenchent depuis les layouts master app et admin. Ensemble, ils couvrent presque toutes les extensions UI qu'un plugin aura jamais besoin de faire — assets en tête de document, widgets en fin de body, et groupes de sidebar admin.

Clé de hookOù vit le callsiteArgs bagUtilisé pour
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php, juste avant @yield('head') [$layout, $context] Balises <link> / <style> / <script> qui doivent se charger avant le contenu spécifique à la page — CSS chatbox, scripts popover sparkle
layout.body.before_close Mêmes fichiers, juste avant </body> [$layout, $context] Widgets flottants qui se montent une fois par page — bulle chatbox, overlays modaux, popovers sparkle
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (pas d'args) Sections de sidebar admin contribuées par plugin — chaque entrée se rend comme un fragment <div class="mc-nav-group">...</div>

Les trois sont collectés via le même idiome côté hôte. Le snippet Blade livré dans resources/views/refactor/layouts/admin.blade.php lit :

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

Trois choses à lire dans ce snippet : collect prend un args bag (ici ['admin']), array_filter écarte chaque contribution null / false / '', et le HTML survivant est émis avec {!! !!} — sans échappement, car c'est du Blade déjà rendu.

Le contrat — renvoyer du HTML ou null

Un rappel REGISTRY pour n'importe lequel des trois slots de layout renvoie l'une de deux choses :

  1. Une chaîne HTML — typiquement le résultat de view('myname::partials.foo')->render(). L'hôte l'émet verbatim avec {!! !!}.
  2. null (ou toute valeur falsy — false, '', 0). L'array_filter de l'hôte l'écarte. C'est la manière conventionnelle de garder une contribution par feature flag, statut de plugin, environnement, ou contexte par-requête.

Renvoyer null est préférable à ne pas s'enregistrer du tout. Le boot() du plugin s'exécute une fois par processus ; décider s'il faut contribuer doit se produire à chaque rendu, pas au boot. La vérification aiPluginAvailable() dans storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 est l'exemple canonique — la closure court-circuite vers null chaque fois que le module IA est désactivé, laissant intacte la contribution de chaque autre plugin.

Le HTML renvoyé doit être auto-contenu. L'hôte dépose le fragment dans le document au callsite sans échappement ni enveloppe supplémentaires. Tout ce dont dépend le fragment — CSS, JS, fichiers de police — doit être déjà chargé au moment du rendu. C'est pourquoi layout.head.assets existe en plus de layout.body.before_close : les fragments de tête se chargent en premier, les fragments de body se montent en dernier, et le plugin peut répartir son enregistrement d'assets entre les deux slots quand il le faut.

L'args-bag — $layout et $context

layout.head.assets et layout.body.before_close passent tous les deux deux arguments positionnels : $layout (une chaîne identifiant quel layout master a déclenché le slot — 'app', 'admin', etc.) et $context (un tableau optionnel portant les props spécifiques à la surface que le layout choisit d'exposer).

// 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'unique clé de hook partagée se déclenche depuis chaque layout master — app, admin, l'email builder, le form builder, l'éditeur d'automation. Le partial du plugin dispatche en interne sur $layout pour rendre le bon jeu d'assets ou la bonne configuration de chatbox. Il n'y a pas de hooks layout.app.head.assets / layout.admin.head.assets séparés ; le nom du layout est juste un discriminant dans un seul args-bag partagé.

Ajouter plus d'arguments positionnels à un slot de layout existant casserait chaque plugin qui binde déjà une closure avec l'arité originale. Le nouveau contexte appartient au tableau $context (qui peut grandir sans changer la signature de closure) ou derrière une clé de hook séparée. Les propres contributions aiHooks de l'hôte gèrent cela exactement — le builder et l'éditeur d'automation passent les props de surface via $context, et le plugin lit $context['kind'], $context['task'], etc. quand présents.

Exemple détaillé — la bulle chatbox acelle/ai

La référence canonique pour l'injection de layout vit à storage/app/plugins/acelle/ai/src/ServiceProvider.php, lignes 678-728. Le plugin contribue aux trois slots de layout, chacun derrière la même garde aiPluginAvailable(). Le bloc d'enregistrement complet, paraphrasé selon le contrat ci-dessus :

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

Ce qui atterrit en production à partir de ces trois blocs : chaque page qui étend le layout app ou admin reçoit le CSS / JS chatbox dans <head>, le HTML de la bulle chatbox avant </body>, et (sur les pages admin uniquement) un groupe sidebar "AI" rendu après les groupes natifs de l'hôte. Aucun fichier Blade de l'hôte n'a été modifié pour faire fonctionner tout cela — le plugin contribue via les clés de hook partagées et l'hôte rend ce qui atterrit.

Le plugin garde la contribution avec aiPluginAvailable(), qui vérifie ai_plugin_active() — un helper qui se résout finalement à Plugin::getByName('acelle/ai')->isActive(). Quand un admin désactive le plugin depuis la page admin Plugins, chaque callback renvoie null à la requête suivante, et l'UI chatbox + sparkle disparaît — sans recharger les routes, supprimer les services enregistrés, ni invalider aucun cache.

admin.sidebar.groups est le plus simple des trois hooks de layout : pas d'args, le rappel renvoie un fragment auto-contenu <div class="mc-nav-group">...</div>. L'hôte le rend après les groupes natifs (Clients, Plans, Settings, ...) et avant toute fermeture de layout. L'ordre est l'ordre d'enregistrement, donc les plugins qui doivent gagner la position de rendu doivent s'enregistrer tard dans boot() après leurs dépendances.

Le groupe sidebar acelle/ai vit à resources/views/partials/admin_sidebar_group.blade.php dans le plugin et rend un groupe "AI" avec trois ou quatre enfants selon les flags de plan. Le même pattern fonctionne pour tout plugin qui a besoin d'une section admin de haut niveau — un plugin de points fidélité, un plugin passerelle de paiement, un plugin driver d'envoi régional.

Slots au niveau page — page.{controller}.{action}.{slot}

L'injection au niveau layout couvre ce qui doit apparaître sur chaque page ; les slots au niveau page couvrent ce qui doit apparaître sur une page spécifique. La convention de nommage rend le binding explicite :

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

Exemples :

  • page.maillist.show.body — cartes additionnelles rendues dans le body de la page détail mail-list
  • page.maillist.verification.body — contenu rendu au-dessus du bloc de statut de vérification
  • page.campaign.index.sidebar — ajouts de sidebar sur la page index de campagne
  • page.customer.edit.footer — ajouts de footer sur la page d'édition client

Le callsite de l'hôte ressemble aux slots de layout — collect, array_filter, émettre chaque fragment avec {!! !!} :


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

Et la contribution du plugin ressemble exactement à une contribution de slot de layout, avec l'args-bag spécifique au slot passé :

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

Les args-bags pour les slots au niveau page portent souvent le modèle pertinent — la mail list, la campagne, le client — pour que le plugin puisse lire ce dont il a besoin sans requête DB supplémentaire. Suivre cette convention garde la signature du hook stable à travers les évolutions de modèles de l'hôte : ajouter un nouveau champ à MailList ne casse la signature de hook d'aucun plugin, car le plugin reçoit toujours le modèle.

Redirections de page — la variante FILTER

Une poignée de hooks de page utilise le pattern FILTER au lieu de REGISTRY — le cas typique est "laissez les plugins décider si l'utilisateur doit être redirigé avant que le cœur ne rende". Le contrat :

// 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 forme est FILTER (transformation chaînée d'une valeur) plutôt que REGISTRY (multiples contributions indépendantes) car une seule redirection peut se produire par requête. Renvoyer l'entrée inchangée depuis la closure est l'opt-out conventionnel — le plugin suivant dans la chaîne voit ce que le précédent a décidé. La première valeur non-null gagne car le contrôleur vérifie if ($redirect) après que la chaîne de filtres soit terminée.

Ce pattern est ce que athena/evs utilise pour router l'utilisateur vers sa propre page de vérification quand le plugin de vérification d'e-mail doit prendre le relais sur la surface de vérification mail-list. La mécanique complète de FILTER vit dans le deep-dive du système de Hooks ; la prise pratique ici est que les redirections au niveau page utilisent FILTER, tandis que le rendu au niveau page utilise REGISTRY.

Publication d'assets — empaqueter CSS / JS / images avec le plugin

Les slots de layout concernent un plugin rend. La publication d'assets concerne ce que le HTML rendu peut référencer. Les plugins qui livrent du CSS, du JavaScript, des polices ou des images utilisent l'API publishes() standard de Laravel, avec le tag 'plugin' que l'hôte connaît :

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

À chaque Plugin::register(), l'hôte exécute artisan vendor:publish --tag=plugin --force, qui copie l'arborescence resources/assets/ du plugin dans public/plugins/{vendor}/{name}/. Les propres partials du plugin référencent les assets via ce chemin :


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

La route qui sert l'icône depuis routes.php (la route nommée plugin.{vendor}.{name}.icon) est l'alternative — au lieu de publier un SVG statique dans public/, le plugin peut exposer une route HTTP qui streame son icône directement depuis storage/app/plugins/{vendor}/{name}/icon.svg. Le compromis : le chemin publié est plus rapide (cacheable par CDN) mais exige une publication à chaque installation ; le chemin routé est auto-contenu mais paie un boot Laravel par requête.

Anti-patterns

1. Renvoyer un objet View Blade au lieu d'une chaîne

L'hôte émet ce que la closure renvoie avec {!! !!}. Renvoyer view('foo') émet le __toString de l'objet, ce qui fonctionne la plupart du temps mais fait perdre à la closure la chance de gérer gracieusement les erreurs de rendu. Correctif : appelez toujours ->render() et renvoyez la chaîne résultante, exactement comme le font les enregistrements acelle/ai.

2. Oublier la branche de garde null

Un rappel REGISTRY qui renvoie toujours du HTML continue de contribuer que le plugin soit actif ou non — car autoloadWithoutDbQuery() charge aussi les plugins inactifs (voir Architecture des plugins § Pourquoi les plugins inactifs affectent toujours l'app). Correctif : gardez avec Plugin::enabled('myvendor/myplugin') ou un helper de feature-flag en haut de chaque closure, renvoyez null quand désactivé.

3. Appeler collect() avec des args supplémentaires que l'hôte n'a pas passés

Les plugins n'appellent pas Hook::collect eux-mêmes — l'hôte le fait. Si un plugin a besoin du nom du layout pour une décision personnalisée, il le lit depuis le premier argument de la closure. Essayer de Hook::collect un slot de layout depuis l'intérieur d'un plugin exécute le callback de chaque autre plugin une fois de plus. Correctif : la closure reçoit tous les args dont elle a besoin ; ne ré-invoquez jamais collect depuis l'intérieur d'un handler enregistré.

4. Faire du travail bloquant à l'intérieur de la closure

La closure s'exécute une fois par rendu de page — un appel API Stripe ou une requête DB de 200 ms à l'intérieur ajoute ce coût à chaque requête. Correctif : précalculez, cachez, ou déplacez vers un loader asynchrone. Le fragment peut renvoyer un placeholder <div data-async-loader> que le JS du plugin hydrate depuis un fetch en arrière-plan.

5. Effets de bord dans un rappel REGISTRY

collect appelle chaque callback dans l'ordre d'enregistrement. Un callback qui écrit dans une session, un cache ou un log en effet de bord rend le hook non déterministe. Deux plugins pourraient courir pour muter la même clé. Correctif : gardez les callbacks add purs — ils existent pour contribuer une valeur, pas pour faire du travail. Si vous avez besoin d'un effet de bord, enregistrez un listener EVENT sur un hook séparé.

6. Scopes CSS qui se chevauchent

Deux plugins injectent du CSS via layout.head.assets ; les deux définissent une classe nommée .mc-popover. L'ordre dans lequel les plugins sont chargés est l'ordre dans lequel leur CSS atterrit, et le dernier gagne. Correctif : namespacez les classes CSS des plugins (.acmecorp-loyalty-popover), ou scopez avec un sélecteur d'attribut sur un élément wrapper. L'hôte ne police pas le CSS des plugins — c'est la discipline de l'auteur du plugin.

Où aller ensuite

L'injection d'UI est la surface la plus demandée pour les nouveaux auteurs de plugins, mais c'est rarement toute la fonctionnalité. Trois pages poussent la même boîte à outils plus loin :