Le modèle mental que le reste de ces docs suppose.

Un plugin dans cette base de code est un petit package Laravel — mais la manière dont l'application hôte le charge n'est pas la manière dont Composer charge normalement les packages. Pas d'étape d'install vendor/, pas d'entrée composer.lock, pas de régénération d'autoload. Chaque plugin est enregistré au runtime, depuis un unique fichier JSON master, par un Composer\Autoload\ClassLoader tout neuf que l'hôte instancie dans AppServiceProvider::boot(). Une fois que vous avez cette image, le reste des docs développeur — hooks, drivers d'envoi, passerelles de paiement, injection UI, cycle de vie — s'emboîte proprement autour.

Ce qu'est un plugin ici

Un plugin est un package Laravel autonome qui vit dans storage/app/plugins/{vendor}/{name}/ au sein de l'installation hôte AcelleMail. Il porte son propre composer.json, son propre namespace PSR-4, son propre ServiceProvider, ses propres routes, vues, migrations et traductions. Il est structuré exactement comme une minuscule application Laravel — à une distinction décisive près.

L'application hôte n'installe pas le plugin via l'autoloader Composer racine. Pas d'étape composer require, pas de répertoire vendor/{vendor}/{name}/, pas d'entrée dans composer.lock. À la place, chaque fois que l'application démarre, elle fait ce qui suit d'elle-même :

  1. Lit le composer.json propre à chaque plugin.
  2. Enregistre le namespace PSR-4 qui y est déclaré avec une instance Composer\Autoload\ClassLoader toute neuve.
  3. Appelle App::register(...) sur les ServiceProviders listés sous extra.laravel.providers.

La décision était délibérée. Traiter les plugins comme des packages installés par Composer aurait fait du composer.json de l'application hôte une cible mouvante — chaque install, désactivation ou upgrade muterait le lockfile. Le chargeur runtime garde le graphe de dépendances de l'hôte stable : les plugins livrent leurs propres métadonnées, et l'hôte peut les scanner, les ignorer ou les réordonner sans toucher à vendor/.

Cinq fichiers qui gouvernent l'ensemble du système

Presque chaque comportement du cycle de vie des plugins est implémenté dans cinq fichiers de l'application hôte. Lire la source de ces fichiers est le moyen le plus rapide de confirmer quoi que ce soit dans cette documentation :

FichierResponsabilité
app/Console/Commands/InitPlugin.phpLe point d'entrée CLI pour php artisan plugin:init. Mince wrapper autour de Plugin::init($name).
app/Model/Plugin.phpL'ensemble du cycle de vie : scaffold, register, load, activate, disable, delete, plus la machinerie du fichier master.
app/Library/HookManager.phpLes primitives d'injection que les plugins utilisent pour étendre le comportement core — REGISTRY, EVENT, BEHAVIOR, FILTER. Environ 160 lignes, pas de dépendances.
app/Providers/AppServiceProvider.phpAutoload de plugin au boot + enregistrement des traductions. L'unique site d'appel qui câble les plugins dans l'application en cours d'exécution.
app/Model/Language.phpMatérialise les fichiers de traduction de plugin dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. L'indirection qui permet aux admins d'éditer les traductions via l'UI Languages sans toucher aux fichiers source des plugins.

Ensemble, cela représente bien moins de trois mille lignes de code côté hôte. Le système de plugins est petit à dessein — chaque contrainte d'un plugin vient de l'un de ces cinq fichiers, et il n'y a nulle part ailleurs où chercher.

Le flux boot-et-chargement

Chaque requête, worker de queue, tick du scheduler et commande Artisan passe par la même séquence de boot. La portion pertinente pour les plugins ressemble à ceci :

application boots
└─ AppServiceProvider::boot()
   └─ Plugin::autoloadWithoutDbQuery()
      └─ reads storage/app/plugins/index.json
         └─ for each entry:
            └─ Plugin::loadPluginByName($name)
               ├─ reads plugin's composer.json
               ├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
               └─ App::register()
                  ├─ ServiceProvider::register()  (early — translations registered here)
                  └─ ServiceProvider::boot()      (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
   and calls $this->loadTranslationsFrom() once per plugin.

Deux détails d'implémentation dans cette séquence ont des conséquences démesurées pour les auteurs de plugins :

1. La découverte au boot n'interroge jamais la base de données

La liste des plugins à charger vient de storage/app/plugins/index.json, pas de la table plugins en base. Les ServiceProviders n'ont pas le droit d'interroger la base de données en toute sécurité — au moment où AppServiceProvider::boot() s'exécute, la connexion peut ne pas exister encore (commandes CLI comme artisan db:create) ou le schéma peut ne pas être migré (setup de tests en CI). Stocker le registre de boot dans un fichier JSON contourne l'ensemble du problème.

La table en base existe toujours. Elle stocke le même status que le fichier JSON, plus des métadonnées orientées utilisateur comme title, description et version. La page admin Plugins lit depuis la base ; le chargeur au boot lit depuis le JSON. Les deux sont maintenus en synchronisation par Plugin::register(), activate() et disable() — chaque changement de statut écrit dans les deux stores.

2. autoloadWithoutDbQuery() charge actuellement chaque plugin de l'index — y compris les inactifs

L'implémentation actuelle itère sur chaque entrée d'index.json et appelle loadPluginByName dessus, indépendamment du status. La raison est pragmatique : même un plugin inactif a besoin que ses routes soient enregistrées (pour que les pages admin continuent de fonctionner quand un admin clique sur "deactivate" sans recharger immédiatement), et il a besoin que ses traductions soient disponibles (pour que les clones dumpés ne deviennent pas obsolètes).

La conséquence est que « inactif » dans le système de plugins AcelleMail n'est pas la même chose que « non chargé ». La section suivante rend la distinction précise.

Le contrat composer.json

Le composer.json d'un plugin n'est pas que de la métadonnée — c'est le contrat runtime dont dépend le chargeur. Les clés qui comptent sont :

CléRôle
nameID canonique du plugin. Doit correspondre exactement au répertoire sous storage/app/plugins/. Plugin::register() lève si ces valeurs divergent.
autoload.psr-4Associe le préfixe de namespace du plugin à src/. Requis — sans cela, loadPluginByName() lève et le plugin ne peut pas démarrer.
extra.laravel.providersTableau de noms de classes pleinement qualifiés. Le chargeur appelle App::register() sur chacun. Requis si le plugin veut enregistrer des routes, vues, hooks ou quoi que ce soit d'autre.
extra.setting-routeLe controller@method vers lequel la page admin Plugins fait pointer le bouton « Settings » du plugin. Optionnel — les plugins sans configuration peuvent l'omettre.
title, description, versionAffiché dans le listing admin Plugins. title est requis ; les autres retombent sur des valeurs par défaut.

L'association d'autoload est enregistrée au runtime, pas à l'install. Vous n'avez pas besoin de lancer composer dump-autoload après avoir édité la map PSR-4 du plugin — l'hôte instancie un ClassLoader tout neuf à chaque requête et relit le fichier. C'est aussi pourquoi changer le namespace d'un plugin ne demande rien de plus qu'un rechercher-remplacer plus une requête vers l'hôte.

Le fichier master (storage/app/plugins/index.json)

Le fichier master est un objet JSON plat indexé par nom de plugin. Chaque entrée stocke au minimum un status, plus une chaîne error optionnelle quand la dernière tentative de boot a échoué. Un fichier typique ressemble à ceci :

{
  "acelle/ai":      { "status": "active" },
  "acmecorp/loyalty": { "status": "inactive" },
  "broken/sample":   { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}

Trois méthodes côté hôte possèdent ce fichier. Chaque changement de statut passe par l'une d'elles :

  • Plugin::updatePluginMasterFile($name, $params) — écriture-merge d'une entrée unique de plugin. Passez null en second argument pour retirer entièrement l'entrée (chemin de suppression).
  • Plugin::resetPluginMasterFile() — reconstruit le fichier depuis zéro en itérant Plugin::all(). Utilisé en récupération quand le JSON est corrompu ou désynchronisé de la base.
  • Plugin::getErroredPluginNames() — lit chaque entrée, renvoie les noms avec error non vide. Le listing admin Plugins l'utilise pour pousser les plugins cassés en bas et faire remonter la pastille rouge d'erreur.

La clé error est définie quand autoloadWithoutDbQuery() enveloppe un appel à loadPluginByName() dans un try/catch et que l'appel lève. Le message de l'exception est enregistré pour que l'UI admin ait quelque chose à afficher sans relancer l'échec. Réactiver un plugin sain efface le champ automatiquement.

Le fichier master est la source unique de vérité au moment du boot. Si vous avez besoin de récupérer un plugin bloqué (l'UI admin est en panne, la base de données est offline), éditez directement storage/app/plugins/index.json. La prochaine requête lit l'état mis à jour et se comporte en conséquence. La ligne en base est la métadonnée long terme ; le fichier JSON est le registre runtime.

Timing register() vs boot()

Laravel exécute d'abord la méthode register() de chaque ServiceProvider, dans l'ordre d'enregistrement, avant d'appeler tout boot(). C'est du Laravel bien connu — mais cela a des conséquences directes dans le système de plugins.

Ce qui va dans register()

  • Les constantes et les bindings — ils doivent exister avant que le propre boot() de l'hôte ne s'exécute.
  • Le hook add_translation_file — et uniquement ce hook. Le AppServiceProvider::boot() de l'hôte appelle Hook::collect('add_translation_file') dans sa propre phase de boot. Au moment où le boot() d'un plugin s'exécute, cette boucle est déjà terminée. Si un plugin enregistre son entrée de traduction dans boot(), elle n'est jamais récupérée — et trans('myname::messages.intro') renvoie la clé littérale.

Ce qui va dans boot()

  • Routes et vues$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
  • Publications d'assets$this->publishes([...], 'plugin').
  • Écouteurs d'événements de cycle de vieHook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
  • L'URL d'icôneHook::set('icon_url_{vendor}/{name}', ...).
  • Tous les autres hooks — REGISTRY add, EVENT on, BEHAVIOR set, FILTER modify. Tout ce qui dépend des bindings de conteneur, de la config ou d'autres plugins.

N'appelez pas $this->loadTranslationsFrom(...) dans le boot() de votre plugin. L'hôte a déjà câblé le namespace via le hook add_translation_file, en le pointant vers les fichiers runtime dumpés sous storage/app/data/plugins/.... Un second loadTranslationsFrom depuis le boot() de votre plugin écrase l'indication de l'hôte et re-pointe le namespace vers le fichier master sous resources/lang/.... Le symptôme visible est que les éditions admin dans l'UI Languages cessent de prendre effet au runtime — les clones dumpés deviennent des fichiers zombies. Utilisez uniquement le hook.

Pourquoi les plugins inactifs affectent quand même l'app

L'appel à autoloadWithoutDbQuery() au boot charge chaque plugin dans index.json indépendamment du statut. Donc un plugin « inactif » a toujours chacun des éléments suivants enregistrés auprès de l'hôte :

  • Ses routes — déclarées par $this->loadRoutesFrom(...) dans boot().
  • Ses vues — déclarées par $this->loadViewsFrom(...).
  • Ses alias de middleware — enregistrés via les API Laravel standards.
  • Ses écouteurs de hooks — chaque Hook::add, Hook::on, Hook::modify, Hook::set se déclenche encore.
  • Ses fragments UI — tout ce qui est contribué via layout.head.assets, layout.body.before_close, admin.sidebar.groups, ou les hooks REGISTRY de page-slot apparaît toujours.

Ce que l'activation ajoute réellement, c'est ce que l'auteur du plugin a câblé à activate_plugin_{vendor}/{name}. L'écouteur du squelette exécute la migration. Il n'y a pas d'étape implicite « enregistrer les routes quand actif » ou « retirer les routes quand inactif » — les routes ont été enregistrées au moment où l'application a démarré.

Si une fonctionnalité doit vraiment disparaître quand un admin désactive le plugin, l'auteur du plugin doit la garder explicitement. Le pattern conventionnel vit dans storage/app/plugins/acelle/console : les routes sont toujours chargées, mais un middleware de route nommé console.active abort en 404 quand Plugin::getByName('acelle/console')->isActive() renvoie false. Copiez ce pattern quand « désactivé » doit signifier « non joignable ».

La même chose s'applique aux hooks UI. Si une bulle de chatbox injectée via layout.body.before_close doit se cacher quand le plugin est inactif, le corps de la closure doit d'abord vérifier Plugin::enabled('myvendor/myplugin') et renvoyer null si false. L'hôte filtre automatiquement les retours falsy avant le rendu.

Cycle de vie : register / activate / disable / delete

Quatre états, quatre méthodes côté hôte. Chacune est précise sur ce qu'elle change et ce qu'elle ne change pas.

Register / install

Plugin::register($name) est le point d'entrée — il est appelé automatiquement à la fin de plugin:init et à chaque upload réussi via l'UI admin. Les cinq étapes sont :

  1. Lit composer.json, copie title / description / version dans le modèle.
  2. Insère ou met à jour la ligne dans plugins avec status = inactive.
  3. Écrit storage/app/plugins/index.json avec { "name": { "status": "inactive" } }.
  4. Appelle Plugin::load($withServiceProvider = true) — enregistre le préfixe PSR-4 et démarre le ServiceProvider immédiatement, de sorte que toutes les routes / vues / hooks deviennent actifs dans le processus courant.
  5. Appelle Language::dump() pour matérialiser les fichiers de traduction, puis exécute vendor:publish --tag=plugin --force pour copier les assets livrés dans public/plugins/....

Après register, le plugin est installé et chargé. La seule chose qui manque est ce que le plugin a choisi de câbler à son événement activate — typiquement l'exécution d'une migration.

Activate

$plugin->activate() est appelée depuis le bouton « Activate » de l'UI admin (et depuis les tests / seeders qui appellent le modèle directement). Elle fait quatre choses, dans l'ordre :

  1. Déclenche Hook::fire('activate_plugin_'.$name). L'écouteur du squelette exécute artisan migrate contre storage/app/plugins/{vendor}/{name}/database/migrations. D'autres plugins peuvent enregistrer des écouteurs supplémentaires — comportement REGISTRY, chaque écouteur se déclenche.
  2. Revérifie le composer.json du plugin par rapport à la liste des clés requises par l'hôte (name, version, app_version).
  3. Positionne le status en base à active.
  4. Met à jour le fichier master : { "status": "active", "error": null } — effaçant toute erreur de boot précédente.

Disable

$plugin->disable() se contente de :

  • Positionner le status en base à inactive.
  • Mettre à jour le fichier master avec le nouveau statut et effacer toute error enregistrée.

Elle ne décharge pas les routes, vues, ServiceProviders, écouteurs de hooks ou quoi que ce soit d'autre enregistré au boot. L'hôte n'a pas de concept « désenregistrer un ServiceProvider » — Laravel lui-même ne le supporte pas. Disable est un changement de statut, pas un déchargement.

Delete

$plugin->deleteAndCleanup($keepData = false) parcourt le teardown complet :

  1. Déclenche Hook::fire('delete_plugin_'.$name, [$keepData]). L'écouteur du squelette exécute migrate:rollback ; $keepData = true peut sauter cela pour les plugins qui possèdent des données que l'admin veut préserver.
  2. Supprime récursivement le répertoire du plugin sous storage/app/plugins/....
  3. Supprime la ligne de la table plugins en base.
  4. Retire l'entrée du fichier master.

Jusqu'à ce que la prochaine requête démarre un processus tout neuf, le ServiceProvider du plugin est toujours chargé en mémoire. La prochaine requête lit le fichier master (désormais rétréci), ne charge pas le plugin, et l'état en mémoire est jeté avec le cycle de vie de la requête.

Deux couches d'injection

Un plugin influence l'application hôte à travers deux couches parallèles. Distinguer entre elles est ce qui fait que le reste de la documentation se mappe proprement au code.

Couche 1 — Enregistrement Laravel

Via le ServiceProvider, un plugin utilise les API standard de conteneur Laravel pour étendre l'application :

  • $this->loadRoutesFrom(__DIR__ . '/../routes.php') — ajoute la surface HTTP du plugin.
  • $this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — expose les vues Blade sous le namespace myname::view.
  • $this->publishes([...], 'plugin') — copie les assets livrés dans public/plugins/{vendor}/{name}/ de l'hôte à l'install.
  • Alias de middleware, bindings de conteneur, commandes console, tâches schedulées, écouteurs de queue — tout ce que Laravel lui-même supporte.

Couche 2 — Injection basée sur Hook

L'hôte appelle dans les primitives App\Library\HookManager à des points d'extension soigneusement choisis. Les plugins enregistrent des écouteurs contre ces points pour participer. Il y a exactement quatre patterns : REGISTRY, EVENT, BEHAVIOR, FILTER. Le deep-dive suivant — Le système Hook — couvre chacun en détail.

Deux choses à savoir maintenant : (1) chaque hook que l'hôte déclenche est un contrat stable — une fois publié, le nom et la signature ne changent pas entre versions. (2) BEHAVIOR est exclusif — si deux plugins essaient de Hook::set le même nom, le second appel lève immédiatement. Pas d'écrasement silencieux ; les conflits font surface au boot, pas en production.

La base de code livre trois hooks REGISTRY au niveau layout que presque chaque plugin étendant l'UI utilise :

Clé de hookOù il se déclencheUtilisé pour
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.php, avant @yield('head')CSS / JS qui doit charger avant le contenu de page (styles de chatbox, scripts de popover sparkle)
layout.body.before_closeMêmes layouts, juste avant </body>Widgets flottants — bulle de chatbox, modales, popover sparkle
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSections de sidebar admin contribuées par des plugins

Les trois suivent le même idiome : chaque callback renvoie du HTML rendu ou null ; l'hôte itère avec array_filter et émet chaque fragment avec {!! !!}. Renvoyer null est la manière conventionnelle de garder une contribution selon un feature flag ou un statut de plugin sans lever d'exception.

Flux de traductions au runtime

Les traductions de plugins ne sont pas servies directement depuis le dossier resources/lang/ source du plugin. Le flux est indirect, et cette indirection est ce qui permet aux admins d'éditer les traductions via l'UI Languages de l'hôte sans commit dans les fichiers source du plugin. La séquence vérifiée :

  1. Le register() du plugin contribue une entrée Hook::add('add_translation_file', ...) pointant vers storage/app/data/plugins/{vendor}/{name}/lang/.
  2. Le AppServiceProvider::boot() de l'hôte collecte toutes ces entrées et appelle $this->loadTranslationsFrom() sur chacune.
  3. À chaque Plugin::register(), l'hôte appelle Language::dump().
  4. Language::dump() lit le fichier master du plugin sous resources/lang/en/messages.php et le copie dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php pour chaque locale supportée.
  5. L'UI admin Languages édite les fichiers runtime dumpés. Le fichier master source du plugin reste intact.

Deux chemins à retenir :

  • Fichier master (vous l'éditez dans la source) : storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
  • Fichiers runtime (auto-générés, ce que l'app lit réellement) : storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php

Quand vous éditez le fichier master, lancez php artisan translation:upgrade pour re-synchroniser le master dans tous les fichiers runtime de locale (en préservant les traductions que les admins ont éditées via l'UI Languages). Le mécanisme complet — master vs runtime, sémantique d'upgrade, fallback par locale — a un deep-dive dédié dans Traductions.

Ce que cela implique pour les auteurs de plugins

Cinq règles découlent de l'architecture ci-dessus. Les intérioriser réduit la majeure partie de la complexité de surface du reste des docs à une vérification contre cette liste.

  1. Traitez boot() comme la phase d'enregistrement. Routes, vues, hooks, écouteurs de cycle de vie — presque tout va ici. La seule chose qui va dans register() est le hook add_translation_file (parce que l'hôte le collecte avant que tout boot() de plugin ne s'exécute).
  2. Inactif ne veut pas dire non chargé. Tout ce que vous enregistrez au boot est actif indépendamment du statut active / inactive. Si une fonctionnalité doit vraiment disparaître une fois désactivée, gardez-la explicitement avec un middleware de route ou un check Plugin::enabled(...) à l'intérieur de la closure du hook.
  3. Éditez les traductions via le fichier master, jamais directement via loadTranslationsFrom(). Les clones dumpés sous storage/app/data/plugins/... sont ce que le runtime lit. Pointer vous-même votre namespace vers le répertoire master écrase l'indication de l'hôte et casse l'UI Languages.
  4. Gardez composer.json mince et stable. Le chargeur runtime le lit à chaque requête. autoload.psr-4, extra.laravel.providers, name, title sont les clés que l'hôte utilise réellement. Ajouter des clés supplémentaires est tolérable mais ne fait rien.
  5. Les quatre patterns de hook sont le seul contrat. Quand vous vous surprenez à vouloir « importer » une classe core pour l'étendre — faites une pause. Le contrat plugin est unidirectionnel : le core déclare les hooks, les plugins réagissent. Si le point d'extension dont vous avez besoin n'existe pas encore comme hook, la bonne démarche est d'ouvrir un ticket contre l'hôte, pas de faire use Acelle\Model\Customer depuis le contrôleur de votre plugin.

Où aller ensuite

Vous avez l'architecture. Deux pages transforment ce modèle mental en API d'usage quotidien que vous solliciterez :

  • Le système Hook — les quatre patterns en profondeur, avec les vrais sites d'appel grepés depuis le core. La sémantique de conflit, quand utiliser quel pattern, et les anti-patterns qui semblent corrects mais cassent en production.
  • Injection UI — les hooks au niveau layout ci-dessus, plus le contrat page.{controller}.{action}.{slot} qui permet à un plugin d'injecter une carte dans une page existante sans forker une seule Blade.

Quand vous êtes prêt à livrer un véritable plugin fonctionnel, les exemples travaillés sont Drivers d'envoi (Postal MTA de bout en bout) et Passerelles de paiement (Paddle comme passerelle régionale). Pour un exercice complet de compréhension de lecture, la vitrine Aurius parcourt le plugin complexe canonique : huit modèles, quatorze migrations, dix-huit locales, et chaque surface de hook utilisée en production.