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:
- 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.
- 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\Setting → settings) 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 è opzionale — onDelete('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 file | Cosa crea / modifica |
2026_04_28_000001_create_ai_conversations_table.php | Sessioni di chat multi-turn — uid, customer_id FK, status enum, rollup di token / cost |
2026_04_28_000002_create_ai_messages_table.php | Singolo turno user / agent — role, content JSON, tool-call FK, latency, model usato |
2026_04_28_000003_create_ai_requests_table.php | Una row per chiamata API upstream — engine, prompt hash, latency, cost, error |
2026_04_28_000004_create_ai_tool_calls_table.php | Invocazioni di function-call generate da un turno agent — nome tool, input/output JSON |
2026_04_28_000005_create_ai_feedback_table.php | Thumbs-up/down + feedback free-text per messaggio + per conversation |
2026_04_28_000006_create_ai_raw_blobs_table.php | Response provider raw originali, tenute per replay / audit |
2026_04_28_000007_create_ai_daily_rollup_table.php | Aggregato per-day per la dashboard admin — totali token, cost, error rate |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | Colonna di dedup cross-tab — additive, senza default |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Traccia se una tool call è arrivata da route agent vs support |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | Fix di larghezza ULID / UUID — migration di alterazione colonna |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Traccia tool action reversibili per la feature "undo last" |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Aggiunge una colonna JSON per telemetria URL sanitizzata |
2026_05_04_000001_create_ai_settings_table.php | Settings 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.