Tabelle isolate per plugin. Nomi con prefisso vendor. Auto-migrate all'attivazione.

I dati di un plugin vivono in tabelle proprie, nello stesso database che usa l'host application, ma con nomi prefissati dall'identità {vendor}_{name} del plugin, così due plugin non possono mai collidere. Le migration vivono dentro la cartella del plugin, girano quando l'admin clicca su Activate e fanno il rollback quando l'admin clicca su Delete. C'è una sola leva canonica — il flag $keepData — per i plugin che possiedono dati utente che l'admin vuole preservare attraverso le re-install.

Perché tabelle isolate per plugin

Un plugin che vuole stato persistente potrebbe usare le tabelle users, customers o plugins dell'host — nessuna di queste sopravviverebbe a un upgrade o a un rename. Il sistema di plugin riserva invece una porzione dello stesso database per le tabelle di proprietà del plugin, isolate per nome. Da questa decisione derivano tre proprietà:

  • Due plugin sulla stessa install non collidono mai. Le tabelle di ogni plugin sono prefissate con l'identità {vendor}_{name} del plugin, che il validator vincola già a lettere minuscole e cifre. La tabella settings di acmecorp/loyalty è acmecorp_loyalty_settings; quella di otherteam/loyalty è otherteam_loyalty_settings. Stesso nome, prefisso diverso.
  • L'attivazione è l'unica cosa che crea tabelle. Il service provider dello skeleton ascolta l'event per-plugin activate_plugin_{vendor}/{name} ed esegue artisan migrate contro la cartella di migration propria del plugin. Finché un admin non attiva, il namespace del plugin è autoloaded ma le sue tabelle non esistono.
  • La cancellazione può essere pulita. Il service provider dello skeleton ascolta anche delete_plugin_{vendor}/{name} ed esegue migrate:rollback. I plugin che possiedono dati che l'admin vuole tenere attraverso le re-install possono fare opt-out tramite il flag $keepData — vedi sotto.

Dove vivono le migration

Le migration del plugin vivono in storage/app/plugins/{vendor}/{name}/database/migrations/. Non vanno nella cartella database/migrations/ della root dell'host application — sono completamente separate. Il php artisan migrate dell'host non le guarda mai, ed è per questo che l'attivazione deve fare il lavoro esplicitamente tramite il lifecycle hook.

Lo skeleton fa lo scaffold di una migration intitolata alla tabella settings del plugin, con un timestamp prefix 2000_01_01_000000_:

storage/app/plugins/acmecorp/loyalty/
└── database/
    └── migrations/
        └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php

Il prefix deliberato 2000_01_01 ordina la migration di scaffold per prima. Le migration reali che aggiungi in seguito ricevono timestamp con la data corrente e girano in ordine cronologico dopo di essa — le normali regole di ordinamento delle migration di Laravel si applicano dentro la cartella del plugin, isolate dall'ordine dell'host.

Activate le esegue; delete fa il rollback

Il file src/ServiceProvider.php dello skeleton contiene due listener di lifecycle che cablano il migration runner agli event di activate / delete. Entrambi vanno in 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'opzione --path dice ad artisan migrate di operare solo contro questa cartella — lascia intatte le migration dell'host e quelle di qualsiasi altro plugin. --force bypassa il prompt di conferma di produzione che artisan migrate normalmente richiede quando APP_ENV=production; l'event di lifecycle è di per sé la conferma utente.

L'attivazione è idempotente — rieseguire il blocco di migration su un plugin già attivato è sicuro. artisan migrate legge la tabella di tracking migrations di Laravel e salta i file già eseguiti. Quindi un admin che clicca Activate due volte (o colpisce per sbaglio l'endpoint REST di activate) ottiene lo stesso end state.

Nomi tabella con prefisso vendor

Due convenzioni nel codebase host insieme prevengono le collisioni:

  1. Il nome stesso del plugin è vincolato a ^[a-z0-9]+\/[a-z0-9]+$ con 2-32 caratteri per lato. Quindi {vendor}_{name} come prefisso non contiene mai uno slash, dash o underscore a cui il parser SQL obietterebbe.
  2. Ogni migration scritta dall'autore del plugin usa il prefisso. Lo scaffolder lo hard-coda — create_{vendor}_{name}_settings_table per la migration inclusa. Le nuove tabelle seguono: {vendor}_{name}_. Esempi da acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.

Il vendor acelle usa una convenzione un po' più lasca — le sue tabelle sono prefissate solo dal nome del plugin (ai) invece di acelle_ai, perché acelle stesso è il vendor host. I plugin di terze parti dovrebbero usare il prefisso completo {vendor}_{name} per lasciare spazio a qualsiasi futuro plugin first-party / host-vendor senza collidere.

La tua prima migration + model

La migration settings dello skeleton è sufficiente per imparare. Usa lo standard Schema builder di Laravel senza wrapper specifici del 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');
    }
};

Il model corrispondente vive in src/Models/Setting.php nel plugin e si lega esplicitamente al nome tabella prefissato:

namespace Acmecorp\Loyalty\Models;

use Illuminate\Database\Eloquent\Model;

class Setting extends Model
{
    protected $table = 'acmecorp_loyalty_settings';
    protected $fillable = ['name', 'value'];
}

Imposta sempre $table esplicitamente — la pluralizzazione snake_cased di default di Laravel (Acmecorp\Loyalty\Models\Settingsettings) punterebbe a una tabella che non esiste (o, peggio, alla tabella settings dell'host se ne esiste una).

Foreign key verso tabelle core

Le tabelle dei plugin referenziano abitualmente customers, users dell'host o altre tabelle di dominio. Aggiungi foreign key nel modo Laravel standard — vivono nella tua migration, puntano alla tabella host e seguono i column type dell'host:

$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
      ->references('id')->on('customers')
      ->onDelete('set null');

Due note operative da ricordare. Primo, mantieni la FK nullable quando la relazione è opzionaleonDelete('set null') lo richiede. Secondo, non fare cascade delle delete sulle tabelle host a meno che i dati del tuo plugin non debbano seguire quando un admin cancella un customer tramite l'UI host. Un plugin di loyalty che fa cascade su customers perderebbe silenziosamente la storia punti di ogni account quando un admin rimuove un singolo test customer; soft-delete sulla tua tabella o scrubbing tramite un queue job è solitamente la scelta giusta.

Esempio reale — le quattordici migration di acelle/ai

Il plugin complesso canonico nel codebase, storage/app/plugins/acelle/ai, spedisce quattordici migration contro tredici tabelle. Sono un utile esercizio di lettura per chiunque pianifichi un plugin con uno schema non banale:

Nome fileCosa crea / modifica
2026_04_28_000001_create_ai_conversations_table.phpSessioni di chat multi-turn — uid, customer_id FK, status enum, rollup di token / cost
2026_04_28_000002_create_ai_messages_table.phpSingolo turno user / agent — role, content JSON, tool-call FK, latency, model usato
2026_04_28_000003_create_ai_requests_table.phpUna row per chiamata API upstream — engine, prompt hash, latency, cost, error
2026_04_28_000004_create_ai_tool_calls_table.phpInvocazioni di function-call generate da un turno agent — nome tool, input/output JSON
2026_04_28_000005_create_ai_feedback_table.phpThumbs-up/down + feedback free-text per messaggio + per conversation
2026_04_28_000006_create_ai_raw_blobs_table.phpResponse provider raw originali, tenute per replay / audit
2026_04_28_000007_create_ai_daily_rollup_table.phpAggregato per-day per la dashboard admin — totali token, cost, error rate
2026_04_29_000001_add_client_message_id_to_ai_messages.phpColonna di dedup cross-tab — additive, senza default
2026_04_30_000002_add_source_to_ai_tool_calls.phpTraccia se una tool call è arrivata da route agent vs support
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpFix di larghezza ULID / UUID — migration di alterazione colonna
2026_05_02_200000_create_ai_tool_undo_records_table.phpTraccia tool action reversibili per la feature "undo last"
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpAggiunge una colonna JSON per telemetria URL sanitizzata
2026_05_04_000001_create_ai_settings_table.phpSettings admin a livello plugin — tenute separate da plugins.data così ogni row può essere indicizzata

Diversi pattern da questa lista si trasferiscono direttamente in altri plugin. Splittare "raw blob" in una tabella separata dal "rolled-up summary" lascia la tabella di rollup abbastanza piccola da fare scan; FK nullable a customers + users lasciano che la stessa row funzioni per traffico autenticato + anonimo; rollup one-row-per-day danno alla dashboard admin letture cheap senza una JOIN pesante contro le tabelle di activity.

Far evolvere lo schema in seguito

Dopo che il plugin è in produzione con customer attivi, le modifiche allo schema sono routine. Il pattern è identico a una normale app Laravel — droppa un nuovo file di migration con timestamp con data corrente in database/migrations/, poi esegui artisan migrate --path=.... Due modi per triggerare:

  • Cold path (release): deattiva il plugin, fai il deploy del nuovo file di migration insieme al resto dell'update del plugin, riattiva. La riattivazione spara l'event activate_plugin_*, che esegue artisan migrate contro il path, che raccoglie i nuovi file.
  • Hot path (durante l'operazione normale): fai il deploy del file, poi chiama direttamente artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force. Il lifecycle hook è comodo ma non magico — è solo un wrapper attorno allo stesso comando.

Le migration di alterazione schema su tabelle che il plugin condivide con l'host (colonne con foreign key, view joinate) richiedono la stessa cura di qualsiasi migration di produzione — colonne additive prima, deploy, backfill, poi drop. Il lifecycle del plugin non cambia le regole.

Il flag $keepData — preservare dati attraverso le re-install

Alcuni plugin possiedono dati che l'admin non vorrebbe perdere se il plugin viene disinstallato e reinstallato. Punti loyalty dei customer, storia feedback AI, audit log dei payment-gateway — nessuno di questi appartiene al bucket "rollback dello schema e dimenticatene". Il lifecycle del plugin gestisce questo con un singolo argomento booleano che l'host spara tramite l'event di 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);
}

Il listener del plugin decide cosa significa $keepData = true nel proprio contesto. Il pattern dello skeleton — skippare interamente il rollback — è un'opzione. Un plugin più sfumato potrebbe fare rollback delle tabelle operative ma preservare i dati 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');
    }
});

Se l'UI host espone una checkbox "keep data" è una decisione per-host; il contratto è in piedi in ogni caso. I plugin che non hanno nulla da preservare possono ignorare l'argomento col default — function ($keepData = false) { ... } funziona sia che l'host passi il flag o no.

Cinque anti-pattern

1. Scrivere nelle tabelle settings, customers o users dell'host

Tentante perché la join è una query in meno, ma cuoce il plugin nello schema dell'host per sempre. Qualsiasi upgrade dell'host che rinomini una colonna rompe il plugin silenziosamente. Fix: scrivi nella tua tabella, FK alla tabella host. La join è cheap, l'accoppiamento resta loose.

2. Dimenticare $table sul tuo model

Senza una proprietà $table esplicita, Laravel pluralizza il nome classe lowercased. Acmecorp\Loyalty\Models\Account risolve a accounts, non acmecorp_loyalty_accounts. Fix: imposta sempre protected $table = '{vendor}_{name}_' sui model del plugin.

3. Delete in cascade su tabelle host

Un admin cancella un singolo test customer; il tuo plugin perde ogni row correlata. Fix: usa onDelete('set null') su FK opzionali, fai soft-delete delle tue row su delete delle tabelle host tramite un queue job e riserva cascade per le tue tabelle child interne.

4. Hardcodare --path della migration in register()

register() gira presto — prima che gli storage path helper dell'host siano affidabili. Fix: il listener activate_plugin_* va in boot(), dove storage_path() e amici sono cablati.

5. Mescolare schema migration e data migration nello stesso file

Una migration che crea una colonna e poi fa il backfill del valore in ogni row rende la disattivazione flaky — il rollback deve invertire anche il backfill. Fix: splitta in due timestamp. La migration di schema è immediatamente reversibile; la migration di data è un file separato che la routine di deactivate del plugin può scegliere di rieseguire, skippare o invertire.

Dove andare dopo

I model coprono la persistence; la prossima pagina è Translations, il flow indiretto che lascia agli admin editare le stringhe del plugin tramite l'UI Languages dell'host senza mai toccare il source del plugin. Dopo, Lifecycle copre in profondità la sequenza four-state boot/activate/disable/delete, e Testing copre il cablaggio phpunit.xml per le test suite del plugin.