Les quatre états en un coup d'œil
Chaque ligne plugin porte une colonne status avec l'une de deux valeurs — active ou inactive. Deux autres états sont implicites : pas encore enregistré (pas de ligne DB, pas d'entrée master file) et supprimé (répertoire de fichiers parti, ligne partie, entrée master file partie). Quatre transitions déplacent le plugin entre eux :
| Transition | Méthode | Statut avant | Statut après | Ce qui change sur disque / DB |
| Register | Plugin::register($name) | (pas de ligne) | inactive | Ligne DB insérée ; entrée master file écrite ; service provider bouté dans la requête courante |
| Activate | $plugin->activate() | inactive | active | Migrations exécutées via le hook activate ; statut DB flippé ; error du master file effacé |
| Disable | $plugin->disable() | active | inactive | Statut DB flippé ; error du master file effacé. Rien d'autre. |
| Delete | $plugin->deleteAndCleanup($keepData = false) | tout | (pas de ligne) | Le hook delete se déclenche (typiquement migrate:rollback) ; dossier du plugin supprimé ; ligne DB supprimée ; entrée master file supprimée |
Le modèle mental à retenir : register et delete changent le monde (fichiers sur disque, schéma DB). Activate et disable ne font que basculer un flag — les routes, vues, hooks et l'état du service-provider issus de register restent en place. Les sections suivantes couvrent chaque transition dans l'ordre.
État 1 — Register / install
Plugin::register($name) dans app/Model/Plugin.php:559 est le point d'entrée. Il est appelé automatiquement à la fin de php artisan plugin:init et à chaque upload réussi via la page admin Plugins. La méthode fait cinq choses distinctes dans l'ordre :
- Lit
composer.json depuis storage/app/plugins/{vendor}/{name}/ et copie title, description, version dans le modèle. Lève une exception si le champ name du composer ne correspond pas exactement au répertoire.
- Insère (ou met à jour) la ligne dans la table DB
plugins avec status = inactive. Le lookup est firstOrNew(['name' => $name]), donc ré-enregistrer un plugin existant met à jour plutôt que de dupliquer.
- Écrit le master file :
storage/app/plugins/index.json reçoit une entrée { "name": { "status": "inactive" } }. C'est le registre boot-time que l'hôte lit à chaque requête sans aller à la DB.
- Charge le service provider immédiatement :
$plugin->load($withServiceProvider = true) enregistre le préfixe PSR-4 avec un nouveau Composer\Autoload\ClassLoader et appelle App::register() sur la classe service provider du plugin. Au moment où la méthode retourne, les routes, vues et hooks du plugin sont câblés dans le processus en cours.
- Matérialise les traductions et publie les assets :
Language::dump() crée des fichiers runtime par locale sous storage/app/data/plugins/{vendor}/{name}/lang/, puis artisan vendor:publish --force --tag=plugin copie les assets empaquetés dans public/plugins/{vendor}/{name}/.
Après register, le plugin est installé et chargé. Il n'est pas encore actif — cela signifie juste que ce que le plugin a choisi de câbler à son événement activate n'a pas tourné. Les routes, vues et listeners de hook du plugin sont déjà live.
État 2 — Activate
$plugin->activate() dans Plugin.php:484 est ce qu'appelle le bouton admin "Activate". Quatre étapes ordonnées :
- Déclenche le hook d'activation :
Hook::fire('activate_plugin_'.$this->name). Chaque listener enregistré contre ce nom s'exécute — typiquement le propre listener Hook::on('activate_plugin_*', ...) du plugin qui appelle artisan migrate contre le dossier de migrations du plugin. D'autres plugins peuvent enregistrer des listeners additionnels sur le même événement.
- Re-valide
composer.json : self::validateMetaData($config) vérifie que les clés requises du plugin (name, version, app_version) sont présentes et bien formées. Les clés manquantes lèvent une exception avant que le flip de statut n'atterrisse.
- Met le statut DB à
active et sauve la ligne.
- Met à jour le master file :
{ "status": "active", "error": null } — le reset de error efface toute défaillance de boot précédente pour que les futurs balayages d'autoload traitent le plugin comme sain.
L'activation est idempotente en pratique. Réexécuter activate() sur un plugin déjà actif re-déclenche le hook (les listeners qui ont fait migrate le referaient — la table de migrations de Laravel dédoublonne les fichiers déjà exécutés, donc la deuxième invocation est un no-op), re-valide et écrit le même statut. Pas de branche "déjà actif" spéciale.
État 3 — Disable
$plugin->disable() dans Plugin.php:136 est la plus simple des quatre méthodes. Elle ne fait que ceci :
- Met le statut DB à
inactive.
- Met à jour le master file avec le nouveau statut et efface tout champ
error.
C'est toute la méthode. Elle ne décharge rien.
Les routes enregistrées pendant le boot() du plugin restent enregistrées. Les vues restent montables. Les listeners de hooks se déclenchent toujours quand l'hôte déclenche leur hook. Le service provider du plugin est toujours chargé dans le conteneur de l'application et sera chargé à nouveau à la requête suivante car autoloadWithoutDbQuery() lit chaque entrée depuis le master file quel que soit le statut. Disable est un flip de statut, pas un unload — Laravel lui-même ne supporte pas le désenregistrement d'un service provider après son boot.
C'est pourquoi le plugin acelle/console est le pattern canonique "les fonctionnalités du plugin doivent disparaître quand désactivé" : les routes se chargent toujours, mais un middleware de route nommé console.active abort en 404 quand Plugin::getByName('acelle/console')->isActive() renvoie false. La vérification se produit à chaque requête, contre le statut DB courant, donc désactiver le plugin fait que ses routes renvoient 404 dès la requête suivante.
Le pattern de désactivation visible en trois étapes. (1) Définissez un middleware de route qui vérifie Plugin::enabled('myvendor/myplugin') et abort 404 quand false. (2) Enregistrez-le comme alias de middleware dans le boot() de votre service provider. (3) Appliquez-le à votre groupe de routes dans routes.php. Chaque plugin qui livre des fonctionnalités visibles par l'utilisateur devrait suivre ce pattern — sans lui, "désactivé" a l'air identique à "activé" du point de vue de l'utilisateur.
État 4 — Delete
$plugin->deleteAndCleanup($keepData = false) dans Plugin.php:670 est le teardown complet. Quatre étapes ordonnées :
- Déclenche le hook de suppression :
Hook::fire('delete_plugin_'.$name, [$keepData]). Le listener du squelette appelle artisan migrate:rollback contre le dossier de migrations du plugin. Le flag $keepData est transmis pour que le listener puisse opt-out du rollback des tables qui contiennent des données client — voir la page database-models pour le pattern détaillé.
- Supprime le répertoire du plugin :
$this->deletePluginDirectory() supprime récursivement storage/app/plugins/{vendor}/{name}/. Après cette étape, le source PHP du plugin est parti du disque.
- Supprime la ligne DB. La table
plugins ne référence plus ce plugin.
- Retire l'entrée master file :
updatePluginMasterFile($name, null) — le null est le signal conventionnel pour supprimer l'entrée plutôt que merger de nouveaux champs.
Jusqu'à ce que la requête suivante boote un processus frais, les routes, vues et hooks du plugin sont encore chargés en mémoire — le conteneur Laravel en-process n'a pas de notion de "désenregistrer le service provider de ce plugin". La requête suivante lit le master file (maintenant 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 précédente.
Le master file à chaque transition
storage/app/plugins/index.json est l'unique source de vérité au boot. Chaque transition ci-dessus y écrit. Une manière utile de voir le cycle de vie est de regarder à quoi ressemble l'entrée d'un plugin à chaque étape :
// Before register: no entry.
{}
// After register:
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After activate:
{
"acmecorp/loyalty": { "status": "active" }
}
// After a boot failure (sticky until cleared by activate):
{
"acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}
// After disable (error cleared, status flipped):
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After delete: no entry.
{}
Trois méthodes côté hôte possèdent le fichier : updatePluginMasterFile($name, $params) pour les merge-writes (passez null en second argument pour supprimer l'entrée), resetPluginMasterFile() pour reconstruire le fichier depuis Plugin::all() quand il se désynchronise de la DB, et getErroredPluginNames() pour lire chaque entrée et renvoyer les noms avec error non vide.
Récupération depuis un état cassé
Trois modes d'échec se manifestent en production :
1. La ligne du plugin dans le master file est obsolète ou incorrecte
Courant après des éditions manuelles, des déploiements partiels, ou la restauration d'un snapshot de base de données. Correctif : exécutez php artisan tinker et appelez Plugin::resetPluginMasterFile(). La méthode itère Plugin::all() depuis la DB et réécrit le fichier JSON de zéro, préservant le statut et effaçant chaque champ error.
2. Le champ error d'un plugin est défini et la page admin Plugins affiche la pastille rouge
L'erreur est collante — définie quand autoloadWithoutDbQuery() enveloppe un appel loadPluginByName() dans try/catch et que l'appel lève. L'erreur reste jusqu'à un activate() réussi (qui met error => null) ou un disable() (idem). Correctif : résolvez le problème sous-jacent (autoload.psr-4 manquant, mismatch de namespace, classe service provider manquante), puis cliquez Activate ; le prochain boot réussira et l'erreur s'effacera.
3. Le dossier du plugin est manquant mais l'entrée master file demeure
Arrive après un rm -rf manuel. Le boot essaie toujours de charger le plugin via l'entrée master file, lève, et enregistre l'erreur. Correctif : retirez l'entrée master file directement avec Plugin::updatePluginMasterFile($name, null), ou — si le plugin doit toujours exister — re-uploadez l'archive source et exécutez Plugin::register($name) à nouveau pour tout repeupler.
Les commandes console plugin:*
Une seule commande artisan est livrée dans l'hôte : plugin:init. Il n'y a pas de commandes plugin:activate, plugin:disable ou plugin:delete — ce sont des actions admin-UI. L'accès programmatique passe directement par les méthodes du modèle :
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate(); // → active, runs migration via activate hook
>>> $p->disable(); // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true); // → preserve customer-facing tables
C'est la même surface qu'utilise la page admin Plugins en interne. Les scripts CI, les seeders et les tests d'intégration attrapent tous ces méthodes directement. Le deep-dive Tests couvre le pattern au niveau testsuite.
Les transitions d'état en un diagramme
┌─────────────────────┐
│ not registered │ (no row, no master-file entry)
└──────────┬──────────┘
│ Plugin::register($name)
│ ├─ writes DB row (status=inactive)
│ ├─ writes master file
│ ├─ loads service provider in-process
│ └─ Language::dump() + vendor:publish
▼
┌─────────────────────┐
┌────▶ │ inactive │ ◀───┐
│ └──────────┬──────────┘ │
│ │ │
│ activate()│ │ disable()
│ │ │ ├─ status=inactive
│ │ │ └─ master file updated
│ ▼ │
│ ┌─────────────────────┐ │
│ │ active │ ────┘
│ └──────────┬──────────┘
│ │
│ deleteAndCleanup($keepData)
│ │ ├─ fires delete hook (rollback unless $keepData)
│ │ ├─ removes plugin folder
│ │ ├─ deletes DB row
│ │ └─ removes master-file entry
│ ▼
│ ┌─────────────────────┐
└──────│ not registered │
└─────────────────────┘
(cycle: register again to re-install)
Cinq anti-patterns
1. Traiter disable comme s'il déchargeait le plugin
Les routes s'enregistrent toujours, les hooks se déclenchent toujours, les vues se montent toujours. Correctif : gardez les fonctionnalités visibles par l'utilisateur avec un middleware Plugin::enabled(...) ou une vérification inline, exactement comme acelle/console.
2. Éditer manuellement le master file en production
Facile de corrompre le JSON. Correctif : appelez Plugin::updatePluginMasterFile() ou Plugin::resetPluginMasterFile() via tinker — les deux valident.
3. rm -rf storage/app/plugins/{vendor}/{name} sans retirer l'entrée master
Le boot continue d'essayer de charger le plugin manquant et enregistre l'erreur. Correctif : appariez toujours une suppression de dossier avec Plugin::updatePluginMasterFile($name, null), ou utilisez deleteAndCleanup() qui fait les deux.
4. Appeler activate() depuis l'intérieur du boot() d'un service provider
La phase de boot s'exécute une fois par processus ; appeler activate() là déclenche le hook d'activation à chaque requête. La migration s'exécute chaque fois (idempotente — mais coûteuse), et les listeners à effets de bord se déclenchent aussi. Correctif : l'activation est une action admin-UI, jamais un effet de bord au boot.
5. Oublier que register arrive avant activate
Certains plugins essaient de seeder des données par défaut via un listener du hook activate et référencent des modèles Eloquent qui dépendent des propres migrations du plugin — mais les migrations n'ont pas encore tourné au premier activate. Correctif : le listener de migration tourne pendant activate, avant tout autre listener Hook::on('activate_plugin_*') qui pourrait référencer les nouvelles tables. Ordonnez vos enregistrements pour que la migration passe en premier (c'est le cas dans le squelette — gardez-le ainsi).
Où aller ensuite
Le cycle de vie couvre le quand ; Tests couvre le vérifier. La page suivante parcourt l'enregistrement de testsuite phpunit.xml, le pattern de classe de base PluginTestCase, les assertions hooks-under-test, et le cycle CI activate-test-delete.