Pourquoi des tables isolées par plugin
Un plugin qui souhaite un état persistant pourrait utiliser les tables users, customers ou plugins de l'hôte — aucune d'elles ne survivrait à une mise à jour ou à un renommage. Le système de plugins réserve à la place une portion de la même base de données pour les tables détenues par les plugins, isolées par leur nom. Trois propriétés découlent de cette décision :
- Deux plugins sur la même installation n'entrent jamais en collision. Les tables de chaque plugin sont préfixées par l'identité
{vendor}_{name} du plugin, que le validateur contraint déjà aux lettres minuscules et chiffres. La table settings d'acmecorp/loyalty est acmecorp_loyalty_settings ; celle d'otherteam/loyalty est otherteam_loyalty_settings. Même nom, préfixe différent.
- L'activation est la seule chose qui crée des tables. Le service provider du squelette écoute l'événement par plugin
activate_plugin_{vendor}/{name} et exécute artisan migrate contre le dossier de migrations du plugin. Tant qu'un administrateur n'a pas activé, le namespace du plugin est autoloadé mais ses tables n'existent pas.
- La suppression peut être propre. Le service provider du squelette écoute également
delete_plugin_{vendor}/{name} et exécute migrate:rollback. Les plugins qui possèdent des données que l'administrateur souhaite conserver entre les réinstallations peuvent se désinscrire via le flag $keepData — voir ci-dessous.
Où résident les migrations
Les migrations de plugin résident dans storage/app/plugins/{vendor}/{name}/database/migrations/. Elles ne vont pas dans le dossier database/migrations/ racine de l'application hôte — elles sont complètement séparées. Le php artisan migrate de l'hôte ne les regarde jamais, c'est pourquoi l'activation doit faire le travail explicitement via le hook de cycle de vie.
Le squelette scaffolde une migration nommée d'après la table settings du plugin, avec un préfixe d'horodatage 2000_01_01_000000_ :
storage/app/plugins/acmecorp/loyalty/
└── database/
└── migrations/
└── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
Le préfixe délibéré 2000_01_01 trie la migration de scaffold en premier. Les vraies migrations que vous ajoutez ensuite reçoivent des horodatages à la date courante et s'exécutent dans l'ordre chronologique derrière elle — les règles d'ordonnancement de migration normales de Laravel s'appliquent au sein du dossier du plugin, isolément de l'ordre de l'hôte.
Activate les exécute ; delete les annule
Le src/ServiceProvider.php du squelette contient deux listeners de cycle de vie qui câblent l'exécution des migrations aux événements activate / delete. Les deux résident dans boot() :
// Run plugin migrations when the plugin is activated.
Hook::on('activate_plugin_acmecorp/loyalty', function () {
\Artisan::call('migrate', [
'--path' => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
'--force' => true,
]);
});
// Roll back plugin migrations when the plugin is deleted.
Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
if ($keepData) {
return;
}
\Artisan::call('migrate:rollback', [
'--path' => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
'--force' => true,
]);
});
L'option --path indique à artisan migrate d'opérer uniquement contre ce dossier — elle laisse intactes les migrations de l'hôte et celles de tout autre plugin. --force contourne l'invite de confirmation en production que artisan migrate exige normalement quand APP_ENV=production ; l'événement de cycle de vie est lui-même la confirmation utilisateur.
L'activation est idempotente — relancer le bloc de migration sur un plugin déjà activé est sans danger. artisan migrate lit la table de suivi migrations de Laravel et saute les fichiers déjà exécutés. Ainsi un administrateur qui clique sur Activate deux fois (ou frappe l'endpoint REST activate par accident) obtient le même état final.
Noms de tables préfixés par vendor
Deux conventions dans la base de code de l'hôte préviennent ensemble les collisions :
- Le nom du plugin lui-même est contraint à
^[a-z0-9]+\/[a-z0-9]+$ avec 2 à 32 caractères de chaque côté. Donc {vendor}_{name} en tant que préfixe ne contient jamais de slash, tiret ou underscore auquel le parseur SQL pourrait s'opposer.
- Chaque migration que l'auteur du plugin écrit utilise le préfixe. Le scaffolder le code en dur —
create_{vendor}_{name}_settings_table pour la migration livrée. Les nouvelles tables suivent : {vendor}_{name}_. Exemples depuis acelle/ai : ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.
Le vendor acelle utilise une convention légèrement plus souple — ses tables ne sont préfixées que par le nom du plugin (ai) au lieu de acelle_ai, parce qu'acelle lui-même est le vendor hôte. Les plugins tiers devraient utiliser le préfixe complet {vendor}_{name} pour laisser de la place à tout futur plugin first-party / vendor hôte sans collision.
Votre première migration + modèle
La migration settings du squelette suffit à servir d'exemple. Elle utilise le builder Schema standard de Laravel sans wrappers spécifiques au plugin :
// storage/app/plugins/acmecorp/loyalty/database/migrations/2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('acmecorp_loyalty_settings', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('acmecorp_loyalty_settings');
}
};
Le modèle correspondant réside dans src/Models/Setting.php du plugin et se lie explicitement au nom de table préfixé :
namespace Acmecorp\Loyalty\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $table = 'acmecorp_loyalty_settings';
protected $fillable = ['name', 'value'];
}
Définissez toujours $table explicitement — la pluralisation snake_case par défaut de Laravel (Acmecorp\Loyalty\Models\Setting → settings) pointerait vers une table qui n'existe pas (ou pire, vers la table settings de l'hôte si elle existe).
Clés étrangères vers les tables core
Les tables de plugin référencent régulièrement les tables customers, users ou d'autres tables de domaine de l'hôte. Ajoutez des clés étrangères de la manière standard Laravel — elles résident dans votre migration, pointent vers la table hôte et suivent les types de colonnes de l'hôte :
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
->references('id')->on('customers')
->onDelete('set null');
Deux notes opérationnelles à retenir. Premièrement, gardez la FK nullable quand la relation est optionnelle — onDelete('set null') l'exige. Deuxièmement, ne mettez pas de cascade sur les suppressions des tables hôtes à moins que les données de votre plugin doivent suivre lorsqu'un administrateur supprime un customer via l'interface hôte. Un plugin loyalty qui cascade sur customers perdrait silencieusement tout l'historique de points de chaque compte quand un administrateur supprime un seul customer de test ; soft-delete sur votre propre table ou nettoyage via un queue job est généralement le bon choix.
Exemple réel — les quatorze migrations d'acelle/ai
Le plugin complexe canonique de la base de code, storage/app/plugins/acelle/ai, livre quatorze migrations contre treize tables. C'est un exercice de lecture utile pour quiconque planifie un plugin au schéma non trivial :
| Nom de fichier | Ce qu'elle crée / modifie |
2026_04_28_000001_create_ai_conversations_table.php | Sessions de chat multi-tours — uid, FK customer_id, enum status, agrégats de tokens / coûts |
2026_04_28_000002_create_ai_messages_table.php | Tour utilisateur / agent unique — rôle, content JSON, FK tool-call, latence, modèle utilisé |
2026_04_28_000003_create_ai_requests_table.php | Une ligne par appel API en amont — engine, hash de prompt, latence, coût, erreur |
2026_04_28_000004_create_ai_tool_calls_table.php | Invocations de function-call générées par un tour d'agent — nom de l'outil, input/output JSON |
2026_04_28_000005_create_ai_feedback_table.php | Pouce levé/baissé + feedback texte libre par message + par conversation |
2026_04_28_000006_create_ai_raw_blobs_table.php | Réponses brutes originales du provider, conservées pour replay / audit |
2026_04_28_000007_create_ai_daily_rollup_table.php | Agrégat par jour pour le tableau de bord admin — totaux de tokens, coût, taux d'erreur |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | Colonne de déduplication transverse — additive, sans valeurs par défaut |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Suit si un tool call vient d'une route agent vs support |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | Correctif de largeur ULID / UUID — migration de modification de colonne |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Suit les actions d'outil réversibles pour la fonctionnalité « undo last » |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Ajoute une colonne JSON pour la télémétrie d'URL assainie |
2026_05_04_000001_create_ai_settings_table.php | Réglages admin au niveau plugin — gardés séparés de plugins.data pour que chaque ligne puisse être indexée |
Plusieurs patterns de cette liste se transposent directement dans d'autres plugins. Séparer les « blobs bruts » dans une table distincte de la « synthèse agrégée » permet à la table d'agrégat de rester assez petite pour être scannée ; des FK nullables vers customers + users permettent à la même ligne de fonctionner pour le trafic authentifié + anonyme ; des agrégats d'une ligne par jour donnent au tableau de bord admin des lectures peu coûteuses sans JOIN lourde contre les tables d'activité.
Faire évoluer le schéma par la suite
Une fois que le plugin est en production avec des customers actifs, les changements de schéma sont routiniers. Le pattern est identique à une application Laravel normale — déposer un nouveau fichier de migration avec un horodatage à la date courante dans database/migrations/, puis lancer artisan migrate --path=.... Deux façons de le déclencher :
- Cold path (releases) : désactivez le plugin, déployez le nouveau fichier de migration avec le reste de la mise à jour du plugin, réactivez. La réactivation déclenche l'événement
activate_plugin_*, qui exécute artisan migrate contre le chemin, qui récupère les nouveaux fichiers.
- Hot path (en cours d'opération normale) : déployez le fichier, puis appelez directement
artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force. Le hook de cycle de vie est pratique mais pas magique — il n'est qu'un wrapper autour de la même commande.
Les migrations qui modifient le schéma sur des tables que le plugin partage avec l'hôte (colonnes avec clé étrangère, vues jointes) demandent le même soin que toute migration en production — colonnes additives d'abord, déploiement, backfill, puis drop. Le cycle de vie du plugin ne change pas les règles.
Le flag $keepData — préserver les données entre les réinstallations
Certains plugins possèdent des données que l'administrateur ne voudrait pas perdre si le plugin était désinstallé puis réinstallé. Points de fidélité customer, historique de feedback IA, logs d'audit de gateway de paiement — aucun ne relève du bucket « annule le schéma et oublie ». Le cycle de vie du plugin gère cela avec un unique argument booléen que l'hôte déclenche via l'événement delete :
// app/Model/Plugin.php — when the host deletes a plugin
public function deleteAndCleanup(bool $keepData = false)
{
Hook::fire('delete_plugin_'.$this->name, [$keepData]);
$this->deletePluginDirectory();
$this->delete();
self::updatePluginMasterFile($this->name, null);
}
Le listener du plugin décide ce que $keepData = true signifie dans son propre contexte. Le pattern du squelette — sauter entièrement le rollback — est une option. Un plugin plus nuancé pourrait annuler les tables opérationnelles tout en préservant les données customer-facing :
Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
\Artisan::call('migrate:rollback', [
'--path' => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
'--force' => true,
]);
if (! $keepData) {
// Drop the customer-facing tables only when the admin
// confirmed they want to start over.
\Schema::dropIfExists('acmecorp_loyalty_accounts');
\Schema::dropIfExists('acmecorp_loyalty_transactions');
}
});
Que l'interface hôte présente une case à cocher « keep data » est une décision propre à chaque hôte ; le contrat est en place dans tous les cas. Les plugins qui n'ont rien à préserver peuvent ignorer l'argument avec la valeur par défaut — function ($keepData = false) { ... } fonctionne que l'hôte passe le flag ou non.
Cinq anti-patterns
1. Écrire dans les tables settings, customers ou users de l'hôte
Tentant parce que la jointure est une requête de moins, mais ça grave le plugin dans le schéma de l'hôte pour toujours. Toute mise à jour hôte qui renomme une colonne casse silencieusement le plugin. Correctif : écrivez dans votre propre table, FK vers la table hôte. La jointure est peu coûteuse, le couplage reste lâche.
2. Oublier $table sur votre modèle
Sans propriété $table explicite, Laravel pluralise le nom de classe en minuscules. Acmecorp\Loyalty\Models\Account se résout en accounts, pas en acmecorp_loyalty_accounts. Correctif : définissez toujours protected $table = '{vendor}_{name}_' sur les modèles de plugin.
3. Suppressions en cascade sur les tables hôtes
Un administrateur supprime un seul customer de test ; votre plugin perd toutes les lignes liées. Correctif : utilisez onDelete('set null') sur les FK optionnelles, soft-delete sur vos propres lignes en réponse aux suppressions de tables hôtes via un queue job, et réservez cascade à vos propres tables enfants internes.
4. Coder en dur le --path de migration dans register()
register() s'exécute tôt — avant que les helpers de chemin de stockage de l'hôte ne soient fiables. Correctif : le listener activate_plugin_* réside dans boot(), là où storage_path() et ses confrères sont câblés.
5. Mélanger migrations de schéma et migrations de données dans le même fichier
Une migration qui crée une colonne puis backfille la valeur dans chaque ligne rend la désactivation fragile — le rollback doit aussi inverser le backfill. Correctif : séparez en deux horodatages. La migration de schéma est immédiatement réversible ; la migration de données est un fichier séparé que la routine de désactivation du plugin peut choisir de relancer, sauter ou inverser.
Où aller ensuite
Les modèles couvrent la persistance ; la page suivante est Traductions, le flux indirect qui permet aux administrateurs de modifier les chaînes du plugin via l'interface Languages de l'hôte sans jamais toucher à la source du plugin. Ensuite, Cycle de vie couvre en profondeur la séquence à quatre états boot/activate/disable/delete, et Tests couvre le câblage phpunit.xml pour les suites de tests de plugin.