D'un terminal vide à un plugin Hello World fonctionnel — en environ cinq minutes.

Une seule commande Artisan scaffolde un package Laravel autonome sous storage/app/plugins/{vendor}/{name}/. Le temps de lire le message de succès, la ligne en base de données est insérée, le fichier master est mis à jour, et le ServiceProvider est déjà démarré dans l'application en cours d'exécution — alors même que le plugin reste inactif. Cette page parcourt la séquence complète de bout en bout, ainsi que les sept erreurs de premier jour qui expliquent la quasi-totalité des échecs rencontrés par les nouveaux auteurs.

Prérequis

Un plugin dans cette base de code est un petit package Laravel. Avant d'en scaffolder un, assurez-vous que l'installation hôte AcelleMail que vous étendez tourne déjà, et que vous disposez d'une chaîne d'outils PHP fonctionnelle sur votre machine locale. Les commandes CLI ci-dessous supposent que vous êtes à la racine de l'application (le répertoire qui contient le fichier artisan).

Application hôte

  • AcelleMail v4.x installé et servant des requêtes. Le chargeur de plugins fait partie de App\Providers\AppServiceProvider — les anciennes versions 3.x ne disposent pas de Plugin::autoloadWithoutDbQuery().
  • Un worker de queue, un scheduler ou une requête web capable de toucher la racine de l'application — le chargeur s'exécute au boot, pas à la demande.
  • Accès en écriture à storage/app/plugins/. La commande Artisan écrit le scaffold ici, pas dans vendor/.

Connaissances PHP à rafraîchir

Le système de plugins s'appuie largement sur une poignée de fondamentaux PHP et Laravel. Si l'un d'eux vous semble rouillé, faites une pause et parcourez la doc pertinente avant de scaffolder — déboguer un plugin dont le namespace est mal déclaré dans son composer.json est bien plus difficile que de le faire correctement du premier coup.

  • Autoloading PSR-4. Le composer.json du plugin associe un préfixe de namespace au répertoire src/. AcelleMail enregistre cette association avec un Composer\Autoload\ClassLoader tout neuf au boot — donc la déclaration de namespace dans chaque fichier PHP doit correspondre exactement à l'association composer.json, capitalisation comprise.
  • Closures et le mot-clé use. Presque chaque écouteur de hook est une closure. Lorsque la closure a besoin d'une variable externe, vous devez la capturer explicitement. L'oublier est la source la plus fréquente d'erreurs undefined variable dans le code de plugin.
  • register() vs boot() sur un ServiceProvider. Laravel exécute d'abord le register() de chaque provider, puis le boot() de chaque provider. Les hooks listés dans register() peuvent s'exécuter avant que leurs dépendances ne soient prêtes ; les hooks listés dans boot() s'exécutent trop tard pour le collecteur de traductions. Les deux sont de véritables footguns — voir Sept erreurs de premier jour.
  • Eloquent, Blade, Routes, Facades. Les migrations de plugin utilisent le builder Schema standard, les vues de plugin sont des fichiers Blade ordinaires, les routes de plugin utilisent Route::group(...). Rien d'un plugin n'est sur mesure — les fichiers générés sont du Laravel vanilla.

Vous n'avez pas besoin de publier le plugin sur Packagist, d'exécuter composer install dans le dossier du plugin, ni d'enregistrer quoi que ce soit dans le composer.json racine de l'hôte. Le chargeur runtime gère chaque étape.

Règles de nommage — lisez-les une fois, gagnez une heure

Chaque plugin a une identité de la forme {vendor}/{name} — par exemple Aurius, aix/sample, athena/evs. Cette identité est la clé canonique dans la table plugins de la base de données, le répertoire storage/app/plugins/, le fichier master storage/app/plugins/index.json, et les noms de hook de cycle de vie (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).

Le validateur dans App\Model\Plugin::init() impose un petit ensemble de règles conservatrices (regex canonique : ^[a-z0-9]+\/[a-z0-9]+$ avec min:2 max:32 par côté) :

  • Lettres minuscules et chiffres uniquement. Pas de tirets bas, pas de tirets, pas de majuscules. La directive antérieure autorisant les tirets bas a été remplacée — si vous voyez my_plugin dans un ancien README, ce n'est plus valide.
  • De deux à trente-deux caractères par côté. a/sample échoue (vendor trop court) ; team/x échoue (name trop court).
  • Exactement une barre oblique. Vendor et name. Pas d'imbrication.

La règle d'intersection conservatrice vient d'un nettoyage en 2026-04 qui a aligné Plugin::init() avec Plugin::getStoragePathByName(). Les deux validateurs s'accordent désormais sur la même regex — il n'y a plus moyen qu'un nom se scaffolde proprement puis échoue au chargement.

Choisissez soigneusement le segment vendor. Le vendor fait partie de chaque namespace, de chaque préfixe d'URL dans le routes.php de votre plugin, et de chaque clé de traduction émise par le plugin. Le renommer plus tard implique un rechercher-remplacer dans tous les fichiers. acmecorp/loyalty est sans ambiguïté ; x/loyalty est invalide (vendor trop court) ; acmecorp/loyaltypoints convient.

La commande de scaffold

Depuis la racine de l'application, exécutez :

php artisan plugin:init {vendor}/{name}

Pour un exemple travaillé, nous utiliserons acmecorp/loyalty — le reste de cette page suppose ce nom. Substituez le vôtre quand vous lancez la commande vous-même.

$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder

Le message de succès est affiché par App\Console\Commands\InitPlugin, qui est un mince wrapper autour de la méthode App\Model\Plugin::init($name) au niveau du modèle. Cette méthode fait tout ce que le reste de la page décrit — validation, copie du scaffold, rendu Twig, renommage des fichiers, puis un appel chaîné à Plugin::register($name) qui insère la ligne en base et démarre le ServiceProvider.

Au moment où le prompt revient, le plugin est déjà chargé dans l'application en cours d'exécution en tant que package inactif. Les routes déclarées dans son routes.php sont accessibles, les vues sont rendables, et tout hook que le ServiceProvider a enregistré est actif. La seule chose que l'activation ajoutera est ce que l'auteur du plugin a câblé à l'événement activate_plugin_{vendor}/{name} — typiquement l'exécution d'une migration.

Ce qui a été généré

La commande Artisan écrit un petit ensemble de fichiers de démarrage dans storage/app/plugins/{vendor}/{name}/, fait le rendu des placeholders Twig à l'intérieur, et renomme la migration placeholder. La liste exacte des fichiers est codée en dur dans Plugin::init() — huit fichiers avec contenu rendu plus quelques assets statiques. Aucun de ces fichiers n'est spécial ; c'est du Laravel vanilla que vous êtes libre de supprimer, renommer ou étendre.

L'arborescence sur disque après la fin de la commande :

storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│   └── migrations/
│       └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│   ├── lang/
│   │   └── en/
│   │       └── messages.php
│   └── views/
│       └── index.blade.php
└── src/
    ├── Controllers/
    │   └── DashboardController.php
    ├── Models/
    │   └── Setting.php
    └── ServiceProvider.php

Les huit fichiers en un coup d'œil

FichierÀ quoi il sert
composer.jsonContrat runtime : name, autoload.psr-4 et extra.laravel.providers sont obligatoires. Sans eux, le chargeur ne peut pas enregistrer le namespace ni démarrer le provider.
src/ServiceProvider.phpLe point d'entrée unique que Laravel voit. Enregistre les traductions dans register(), puis les routes, vues, hooks de cycle de vie et l'URL d'icône dans boot().
src/Controllers/DashboardController.phpUn exemple jetable. Renvoie la vue index.blade.php livrée. À remplacer librement.
src/Models/Setting.phpUn modèle Eloquent lié à la première migration du plugin. Le nom de la table est nommé en espace via {vendor}_{name}_settings de sorte que les plugins ne puissent pas entrer en collision sur la même base.
routes.phpChargé depuis le ServiceProvider. Déclare à la fois la route servant l'icône (utilisée par la page admin Plugins) et une route de tableau de bord d'exemple plugins/{vendor}/{name}.
resources/views/index.blade.phpLa vue Hello World rendue par DashboardController. À remplacer par votre véritable UI.
resources/lang/en/messages.phpLe fichier de traduction master. Language::dump() le copie dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ au runtime — ce sont les fichiers dumpés que l'application lit réellement.
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.phpLa première migration. S'exécute uniquement lorsque le plugin est activé, est rollback lorsqu'il est supprimé. Le nom de fichier est le seul dont les placeholders ne sont pas rendus par Twig lui-même — Plugin::init() le renomme via une passe str_replace séparée.

Un véritable plugin de production dépasse cette surface minimale. La référence canonique dans la base de code est storage/app/plugins/Aurius/ — huit modèles Eloquent, quatorze migrations, dix-huit locales, plus de soixante vues, un groupe dans la sidebar admin, une bulle UI de chatbox, et ses propres jobs liés à la queue. Le squelette Hello World est intentionnellement minimal pour que vous puissiez remplacer les pièces une à une sans apprendre tous les sous-systèmes en même temps. Les contrôleurs supplémentaires vont sous src/Controllers/, les modèles supplémentaires sous src/Models/, les services supplémentaires sous src/Services/, les migrations supplémentaires sous database/migrations/.

Ce que Plugin::register() a fait en coulisses

La ligne de sortie dit created & loaded, et c'est précis. Entre la copie des fichiers et l'affichage du message de succès, Plugin::init() appelle Plugin::register($name), qui effectue cinq étapes distinctes :

  1. Lit le composer.json du plugin. Le champ name doit correspondre exactement au répertoire (acmecorp/loyalty) — une divergence lève une exception composer name in composer.json is expected to be ….
  2. Crée ou met à jour la ligne dans la table plugins. title, description et version sont tirés des métadonnées composer. Le statut est positionné à inactive.
  3. Écrit le fichier master. storage/app/plugins/index.json est le registre de boot — AppServiceProvider::boot() lit ce fichier pour décider quels plugins autoloader, à chaque requête, sans toucher à la base de données. L'activation et la désactivation modifient ensuite le même fichier.
  4. Charge immédiatement le ServiceProvider. Le boot() du plugin s'exécute dans le processus courant, donc toutes les routes / vues / hooks qu'il enregistre sont actifs avant la prochaine requête.
  5. Matérialise les fichiers de traduction. Language::dump() lit chaque entrée de hook add_translation_file, copie les fichiers master dans storage/app/data/plugins/..., et termine en exécutant vendor:publish --tag=plugin --force pour que tout asset livré atterrisse sous public/plugins/....

Le modèle mental à retenir : « installé » signifie déjà « chargé ». L'activation est purement un changement de statut plus ce que l'auteur du plugin a câblé à l'événement d'activation. Il n'y a pas d'étape distincte enregistrer les routes que l'activation déclencherait — les routes sont enregistrées dès la fin de plugin:init.

Les plugins inactifs sont toujours chargés. L'implémentation actuelle de Plugin::autoloadWithoutDbQuery() charge chaque plugin listé dans index.json, indépendamment du statut. Si une fonctionnalité doit vraiment disparaître lorsque l'admin désactive le plugin, l'auteur du plugin doit la garder explicitement — un middleware de route qui vérifie Plugin::getByName($name)->isActive() et abort en 404 est le pattern conventionnel. Le plugin admin-console de la plateforme elle-même en est l'exemple canonique.

Activer le plugin

Avec le plugin scaffolde et inactif, l'étape suivante est de le marquer comme actif afin que son écouteur activate_plugin_{vendor}/{name} exécute la migration. Deux chemins :

Depuis l'UI admin

Connectez-vous en tant qu'admin, ouvrez /rui/admin/plugins, trouvez l'entrée Loyalty et cliquez sur Activate. La page affiche l'icône servie par votre routes.php (le placeholder livre un icon.svg à la racine du plugin — remplacez-le par le vôtre pour personnaliser l'entrée).

Par programmation (test ou seeding)

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated

L'un comme l'autre des chemins déclenche Hook::fire('activate_plugin_acmecorp/loyalty'). Le ServiceProvider du squelette a enregistré un écouteur Hook::on(...) pour cet événement dans boot() — l'écouteur appelle Artisan::call('migrate', ['--path' => ..., '--force' => true]), ce qui crée la table acmecorp_loyalty_settings.

Visitez /plugins/acmecorp/loyalty dans un navigateur et la page Hello World livrée s'affiche. Le blockquote @{{ trans('loyalty::messages.intro') }} tire du fichier de traduction dumpé sous storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.

Vos premières modifications

Le squelette est intentionnellement minimal pour que vous puissiez remplacer les pièces une à une sans apprendre tous les sous-systèmes en même temps. Un ordre raisonnable :

  1. Mettez à jour composer.json. Définissez de vrais title, description et version. La page admin Plugins les affiche.
  2. Ajoutez une vraie migration. Déposez un nouveau fichier sous database/migrations/ avec un timestamp supérieur à celui existant. Elle s'exécutera au prochain activate (ou après un cycle deactivate-puis-reactivate).
  3. Ajoutez un vrai modèle. Le squelette livre Setting comme placeholder. Ajoutez le vôtre sous src/Models/ ; nommez-le en espace via {Vendor_class}\{Name_class}\Models\YourModel. Les noms de classe sont auto-dérivés du vendor/name en minuscules — acmecorp devient Acmecorp et loyalty devient Loyalty.
  4. Remplacez DashboardController. Ajoutez les contrôleurs dont votre fonctionnalité a réellement besoin. Gardez-les minces — poussez la logique métier dans des classes src/Services/.
  5. Remplacez les vues. Le index.blade.php livré utilise Bootstrap 5 depuis un CDN. La plupart des auteurs de plugins l'enlèvent et étendent plutôt le layout de l'application hôte.
  6. Ajoutez des hooks dans ServiceProvider::boot(). Voir le deep-dive du système Hook pour les quatre patterns. Le squelette démontre déjà EVENT (Hook::on) et BEHAVIOR (Hook::set) — REGISTRY et FILTER sont les deux suivants à apprendre.

Sept erreurs de premier jour et comment les corriger

Presque chaque signalement des nouveaux auteurs de plugins entre dans l'une de ces sept catégories. Chacune est ancrée dans du code livré dans App\Model\Plugin ou App\Providers\AppServiceProvider, donc les symptômes sont prévisibles.

1. Le nommage viole le validateur

plugin:init lève avec Plugin name must be in the "author/name" format ou Author name "..." is invalid. Only lowercase letters and digits are allowed. Cause : la regex ^[a-z0-9]+\/[a-z0-9]+$ avec min:2 max:32 par côté rejette les tirets bas, les tirets, les majuscules, ou les côtés plus courts que deux caractères.

Correctif : utilisez uniquement des lettres minuscules et des chiffres — par exemple acmecorp/loyalty, pas acme_corp/loyalty-points.

2. Le nom composer.json ne correspond pas au dossier

Après le scaffold, Plugin::register() vérifie que le name dans le composer.json rendu correspond au dossier sous storage/app/plugins/. Éditer le JSON vers un vendor ou un name différent sans renommer le répertoire lève Plugin name in composer.json is expected to be '{folder}', found '{json}'.

Correctif : renommez le répertoire et le JSON de concert, ou relancez plugin:init avec le nouveau nom.

3. autoload.psr-4 manquant ou malformé

loadPluginByName() lève Cannot boot plugin '{name}'. No 'autoload' found in composer.json (ou la variante équivalente 'autoload.psr4') quand le bloc autoload est supprimé ou mal orthographié. Le runtime a besoin de cette map pour enregistrer le namespace ; sans elle, rien dans src/ ne peut être instancié.

Correctif : conservez l'entrée autoload.psr-4 scaffolde. Le préfixe de namespace qu'elle déclare (Acmecorp\Loyalty\\) doit correspondre à la déclaration de namespace en tête de chaque fichier PHP sous src/.

4. La déclaration de namespace ne correspond pas à composer.json

L'autoloader de PHP résout Acmecorp\Loyalty\Controllers\DashboardController en src/Controllers/DashboardController.php en supprimant le préfixe Acmecorp\Loyalty\\ déclaré dans composer.json. Si le fichier déclare namespace AcmeCorp\Loyalty\Controllers (C majuscule dans AcmeCorp), l'autoloader ne le trouve pas. Symptômes : Class "Acmecorp\Loyalty\Controllers\DashboardController" not found dès la toute première requête.

Correctif : la déclaration de namespace dans chaque fichier PHP sous src/ doit utiliser la capitalisation exacte dérivée du vendor/name en minuscules. Pour acmecorp/loyalty, c'est Acmecorp\Loyalty. Plugin::makeClassNameFromString() n'applique que ucfirst — il n'y a pas de casing intelligent.

5. Hook de traduction enregistré dans boot() au lieu de register()

AppServiceProvider::boot() 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 — y ajouter l'entrée de traduction signifie qu'elle n'est jamais récupérée, et trans('loyalty::messages.intro') renvoie la clé littérale.

Correctif : enregistrez les traductions dans register(), exactement comme le squelette le fait. Les hooks de cycle de vie pour activate_plugin_* et delete_plugin_* appartiennent toujours à boot().

6. Appel de $this->loadTranslationsFrom(...) dans boot()

Un réflexe courant est d'appeler directement loadTranslationsFrom() de Laravel en plus du hook. Comme le boot() du plugin s'exécute après AppServiceProvider::boot, le second appel écrase l'indication de namespace qui pointait vers les fichiers runtime dumpés (storage/app/data/plugins/...) et la re-pointe vers le fichier master (storage/app/plugins/.../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.

Correctif : utilisez uniquement le hook add_translation_file. N'appelez pas aussi loadTranslationsFrom().

7. Hooks enregistrés dans register() qui dépendent d'autres plugins ou du kernel

register() s'exécute avant que tous les autres register() de providers ne soient terminés et bien avant tout boot(). Du code qui a besoin de la base de données, des services d'un autre plugin, ou d'un singleton câblé dans le register() d'un autre provider peut échouer avec Class not found ou Target class does not exist. Le seul hook qui appartient à register() est add_translation_file (parce qu'il doit s'exécuter avant la boucle collect de AppServiceProvider::boot).

Correctif : placez tout autre hook dans boot(). Si vous avez absolument besoin que quelque chose s'exécute tôt, gardez-le par app()->runningInConsole() ou isInitiated() d'abord.

Check-list étape par étape

La séquence complète pour livrer un plugin fonctionnel, de bout en bout :

  1. php artisan plugin:init {vendor}/{name} — scaffold.
  2. Éditer composer.json — définir de vrais title, description, version.
  3. Écrire vos migrations sous database/migrations/.
  4. Ajouter des modèles sous src/Models/.
  5. Ajouter des contrôleurs sous src/Controllers/.
  6. Ajouter des vues sous resources/views/.
  7. Déclarer les routes dans routes.php.
  8. Câbler le tout dans ServiceProvider::boot() — vues, routes, hooks, publications d'assets.
  9. Se connecter à admin → Plugins → Activate. La migration s'exécute automatiquement.

Lorsque quelque chose tourne mal, deux points d'entrée de débogage couvrent presque tous les cas. storage/logs/laravel.log capture toute exception levée pendant le boot, y compris celles levées dans loadPluginByName() lors de l'enregistrement de l'autoload. Le champ error sur chaque ligne de storage/app/plugins/index.json montre l'échec de boot le plus récent pour ce plugin et c'est ce que la page admin Plugins utilise pour faire remonter la pastille rouge d'erreur — vider le fichier en réactivant le plugin (ou en supprimant puis réinstallant) réinitialise l'état d'erreur.

Où aller ensuite

Vous avez le scaffold, le cycle de vie et les sept erreurs qui verrouillent l'essentiel du débogage du premier jour. Les deux pages suivantes vous donnent le modèle mental que le reste de la documentation suppose :

  • Architecture des plugins — le flux de chargement au boot, pourquoi les plugins inactifs sont quand même autoloadés, le mécanisme du fichier master, et la différence entre register() et boot() au niveau runtime.
  • Le système Hook — les quatre patterns (REGISTRY, EVENT, BEHAVIOR, FILTER), quand recourir à chacun, et la sémantique de conflit qui fait que BEHAVIOR lève en cas de collision plutôt que d'écraser silencieusement.

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 le travail UI, Injection UI couvre les hooks layout/sidebar/page-slot qui permettent à un plugin de monter une bulle de chatbox ou un panneau de réglages sans forker une seule Blade.