Tables isolées par plugin. Noms préfixés par vendor. Migration auto à l'activation.

Les données d'un plugin résident dans ses propres tables, dans la même base de données que celle utilisée par l'application hôte, mais avec des noms préfixés par l'identité {vendor}_{name} du plugin afin que deux plugins ne puissent jamais entrer en collision. Les migrations résident dans le dossier du plugin, s'exécutent quand l'administrateur clique sur Activate et sont annulées quand l'administrateur clique sur Delete. Il existe un unique levier canonique — le flag $keepData — pour les plugins qui possèdent des données utilisateur que l'administrateur souhaite conserver entre les réinstallations.

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 :

  1. 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.
  2. 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\Settingsettings) 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 optionnelleonDelete('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 fichierCe qu'elle crée / modifie
2026_04_28_000001_create_ai_conversations_table.phpSessions de chat multi-tours — uid, FK customer_id, enum status, agrégats de tokens / coûts
2026_04_28_000002_create_ai_messages_table.phpTour utilisateur / agent unique — rôle, content JSON, FK tool-call, latence, modèle utilisé
2026_04_28_000003_create_ai_requests_table.phpUne ligne par appel API en amont — engine, hash de prompt, latence, coût, erreur
2026_04_28_000004_create_ai_tool_calls_table.phpInvocations 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.phpPouce levé/baissé + feedback texte libre par message + par conversation
2026_04_28_000006_create_ai_raw_blobs_table.phpRéponses brutes originales du provider, conservées pour replay / audit
2026_04_28_000007_create_ai_daily_rollup_table.phpAgré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.phpColonne de déduplication transverse — additive, sans valeurs par défaut
2026_04_30_000002_add_source_to_ai_tool_calls.phpSuit si un tool call vient d'une route agent vs support
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpCorrectif de largeur ULID / UUID — migration de modification de colonne
2026_05_02_200000_create_ai_tool_undo_records_table.phpSuit les actions d'outil réversibles pour la fonctionnalité « undo last »
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpAjoute une colonne JSON pour la télémétrie d'URL assainie
2026_05_04_000001_create_ai_settings_table.phpRé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.