Warum plugin-isolierte Tabellen
Ein Plugin, das persistenten State braucht, könnte die users-, customers- oder plugins-Tabellen des Hosts mitbenutzen — keine davon überstünde ein Upgrade oder ein Rename. Das Plugin-System reserviert stattdessen einen Teil derselben Datenbank für plugin-eigene Tabellen, namensisoliert. Drei Eigenschaften ergeben sich aus dieser Entscheidung:
- Zwei Plugins auf derselben Installation kollidieren nie. Die Tabellen jedes Plugins sind durch die
{vendor}_{name}-Identität des Plugins präfixiert, die der Validator ohnehin auf Kleinbuchstaben und Ziffern beschränkt. Die Settings-Tabelle von acmecorp/loyalty ist acmecorp_loyalty_settings; die von otherteam/loyalty ist otherteam_loyalty_settings. Gleicher Name, unterschiedlicher Prefix.
- Nur die Aktivierung erstellt Tabellen. Der Service Provider des Skeletons hört auf das per-Plugin-Event
activate_plugin_{vendor}/{name} und führt artisan migrate gegen den eigenen Migrations-Ordner des Plugins aus. Bis ein Admin aktiviert, ist der Namespace des Plugins zwar autoloaded, seine Tabellen existieren aber nicht.
- Deletion kann sauber sein. Der Service Provider des Skeletons hört außerdem auf
delete_plugin_{vendor}/{name} und führt migrate:rollback aus. Plugins, deren Daten der Admin über Re-Installs behalten will, können sich über das $keepData-Flag abmelden — siehe unten.
Wo Migrationen liegen
Plugin-Migrationen liegen unter storage/app/plugins/{vendor}/{name}/database/migrations/. Sie gehören nicht in den Root-Ordner database/migrations/ der Host-Anwendung — sie sind vollständig getrennt. Das php artisan migrate des Hosts schaut sie nie an, weshalb die Aktivierung die Arbeit explizit über den Lifecycle-Hook erledigen muss.
Das Skeleton gerüstet eine Migration mit dem Namen der Settings-Tabelle des Plugins, mit dem Timestamp-Prefix 2000_01_01_000000_:
storage/app/plugins/acmecorp/loyalty/
└── database/
└── migrations/
└── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
Der bewusste Prefix 2000_01_01 sortiert die Skeleton-Migration nach vorne. Echte Migrationen, die Sie später hinzufügen, bekommen aktuelle Datumsstempel und laufen in chronologischer Reihenfolge dahinter — die normalen Migrations-Sortierregeln von Laravel gelten innerhalb des Plugin-Ordners, isoliert von der Reihenfolge des Hosts.
Activate führt sie aus; Delete rollt sie zurück
Der src/ServiceProvider.php des Skeletons enthält zwei Lifecycle-Listener, die den Migrations-Runner an die Activate- / Delete-Events verdrahten. Beide gehören 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,
]);
});
Die --path-Option teilt artisan migrate mit, nur gegen diesen Ordner zu arbeiten — Migrationen des Hosts und anderer Plugins bleiben unberührt. --force umgeht die Produktions-Bestätigungs-Eingabeaufforderung, die artisan migrate normalerweise verlangt, wenn APP_ENV=production gesetzt ist; das Lifecycle-Event ist selbst die Bestätigung des Nutzers.
Die Aktivierung ist idempotent — den Migrations-Block auf einem bereits aktivierten Plugin erneut laufen zu lassen, ist sicher. artisan migrate liest Laravels migrations-Tracking-Tabelle und überspringt bereits ausgeführte Dateien. Ein Admin, der zweimal auf Activate klickt (oder versehentlich den Activate-REST-Endpoint trifft), erhält denselben Endzustand.
Vendor-präfixierte Tabellennamen
Zwei Konventionen im Host-Codebase verhindern gemeinsam Kollisionen:
- Der Plugin-Name selbst ist eingeschränkt auf
^[a-z0-9]+\/[a-z0-9]+$ mit 2-32 Zeichen pro Seite. {vendor}_{name} als Prefix enthält also nie einen Slash, Bindestrich oder Unterstrich, an dem sich der SQL-Parser stören würde.
- Jede Migration, die der Plugin-Autor schreibt, nutzt den Prefix. Der Scaffolder hartcodiert das —
create_{vendor}_{name}_settings_table für die mitgelieferte Migration. Neue Tabellen folgen dem Schema: {vendor}_{name}_. Beispiele aus acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.
Der Vendor acelle nutzt eine etwas lockerere Konvention — seine Tabellen sind nur durch den Plugin-Namen (ai) statt durch acelle_ai präfixiert, weil acelle selbst der Host-Vendor ist. Drittanbieter-Plugins sollten den vollen {vendor}_{name}-Prefix verwenden, um Raum für jedes künftige First-Party- bzw. Host-Vendor-Plugin zu lassen, ohne zu kollidieren.
Ihre erste Migration + Model
Die Settings-Migration des Skeletons reicht zum Lernen aus. Sie nutzt den Standard-Laravel-Schema-Builder ohne plugin-spezifische Wrapper:
// 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');
}
};
Das passende Model liegt in src/Models/Setting.php im Plugin und bindet explizit an den präfixierten Tabellennamen:
namespace Acmecorp\Loyalty\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $table = 'acmecorp_loyalty_settings';
protected $fillable = ['name', 'value'];
}
Setzen Sie $table immer explizit — Laravels Default-Snake-Case-Pluralisierung (Acmecorp\Loyalty\Models\Setting → settings) zeigt auf eine Tabelle, die nicht existiert (oder, schlimmer noch, auf die settings-Tabelle des Hosts, falls eine existiert).
Foreign Keys auf Core-Tabellen
Plugin-Tabellen referenzieren routinemäßig die customers-, users- oder andere Domain-Tabellen des Hosts. Fügen Sie Foreign Keys auf dem Standard-Laravel-Weg hinzu — sie leben in Ihrer Migration, zeigen auf die Host-Tabelle und folgen den Spaltentypen des Hosts:
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
->references('id')->on('customers')
->onDelete('set null');
Zwei operative Notizen sollten Sie sich merken. Erstens, halten Sie den FK nullable, wenn die Beziehung optional ist — onDelete('set null') setzt das voraus. Zweitens, cascaden Sie keine Deletions auf Host-Tabellen, es sei denn, die Daten Ihres Plugins sollen verschwinden, wenn ein Admin einen Kunden über die Host-UI löscht. Ein Loyalty-Plugin, das auf customers cascadet, würde still die Punkte-Historie jedes Accounts verlieren, sobald ein Admin einen einzelnen Test-Kunden entfernt; auf der eigenen Tabelle soft-deleten oder per Queue-Job aufräumen ist meistens die richtige Wahl.
Praxisbeispiel — die vierzehn Migrationen von acelle/ai
Das kanonische komplexe Plugin im Codebase, storage/app/plugins/acelle/ai, liefert vierzehn Migrationen gegen dreizehn Tabellen aus. Sie sind eine nützliche Leseübung für alle, die ein Plugin mit nicht-trivialem Schema planen:
| Dateiname | Was angelegt / verändert wird |
2026_04_28_000001_create_ai_conversations_table.php | Multi-Turn-Chat-Sessions — uid, customer_id-FK, status-Enum, Token-/Kosten-Rollups |
2026_04_28_000002_create_ai_messages_table.php | Einzelner User- / Agent-Turn — Rolle, Content-JSON, Tool-Call-FK, Latenz, verwendetes Modell |
2026_04_28_000003_create_ai_requests_table.php | Eine Zeile pro Upstream-API-Aufruf — Engine, Prompt-Hash, Latenz, Kosten, Fehler |
2026_04_28_000004_create_ai_tool_calls_table.php | Function-Call-Aufrufe, ausgelöst durch einen Agent-Turn — Tool-Name, Input-/Output-JSON |
2026_04_28_000005_create_ai_feedback_table.php | Daumen hoch/runter + Freitext-Feedback pro Nachricht + pro Konversation |
2026_04_28_000006_create_ai_raw_blobs_table.php | Original-Rohantworten der Provider, für Replay / Audit aufbewahrt |
2026_04_28_000007_create_ai_daily_rollup_table.php | Tagesaggregate für das Admin-Dashboard — Token-Summen, Kosten, Fehlerquote |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | Spalte für Cross-Tab-Dedup — additiv, keine Defaults |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Hält fest, ob ein Tool-Call vom Agent oder von der Support-Route kam |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | ULID-/UUID-Breitenfix — spaltenverändernde Migration |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Verfolgt reversible Tool-Aktionen für das „Letzte Aktion rückgängig"-Feature |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Fügt eine JSON-Spalte für bereinigte URL-Telemetrie hinzu |
2026_05_04_000001_create_ai_settings_table.php | Plugin-weite Admin-Settings — getrennt von plugins.data, damit jede Zeile indizierbar bleibt |
Mehrere Patterns aus dieser Liste lassen sich direkt auf andere Plugins übertragen. „Raw Blobs" von „Roll-up-Zusammenfassungen" zu trennen, hält die Rollup-Tabelle klein genug zum Scannen; nullable FKs auf customers + users lassen dieselbe Zeile für authentifizierten und anonymen Traffic funktionieren; ein Rollup pro Tag liefert dem Admin-Dashboard günstige Reads ohne schweren JOIN gegen die Activity-Tabellen.
Das Schema später weiterentwickeln
Nachdem das Plugin mit aktiven Kunden in Produktion ist, sind Schema-Änderungen Routine. Das Pattern ist identisch mit einer normalen Laravel-App — eine neue Migrations-Datei mit aktuellem Datumsstempel in database/migrations/ ablegen, dann artisan migrate --path=... ausführen. Zwei Wege, das auszulösen:
- Cold Path (Releases): Deaktivieren Sie das Plugin, deployen Sie die neue Migrations-Datei mit dem Rest des Plugin-Updates und reaktivieren Sie. Die Reaktivierung feuert das
activate_plugin_*-Event, das artisan migrate gegen den Pfad ausführt, welches die neuen Dateien aufgreift.
- Hot Path (im laufenden Betrieb): Deployen Sie die Datei und rufen Sie direkt
php artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force auf. Der Lifecycle-Hook ist bequem, aber nicht magisch — er ist nur ein Wrapper um denselben Befehl.
Schema-verändernde Migrationen auf Tabellen, die das Plugin mit dem Host teilt (per Foreign Key verbundene Spalten, verknüpfte Views), brauchen dieselbe Sorgfalt wie jede Produktions-Migration — additive Spalten zuerst, deployen, backfillen, dann droppen. Der Plugin-Lifecycle ändert daran nichts.
Das $keepData-Flag — Daten über Re-Installs hinweg erhalten
Manche Plugins besitzen Daten, die der Admin beim Deinstallieren und Neuinstallieren nicht verlieren möchte. Loyalty-Punkte von Kunden, AI-Feedback-Historie, Audit-Logs eines Payment-Gateways — keines davon gehört in den „Schema zurückrollen und vergessen"-Eimer. Der Plugin-Lifecycle erledigt das mit einem einzelnen boolschen Argument, das der Host durch das Delete-Event feuert:
// 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);
}
Der Listener des Plugins entscheidet, was $keepData = true im eigenen Kontext bedeutet. Das Pattern des Skeletons — Rollback komplett überspringen — ist eine Möglichkeit. Ein differenzierteres Plugin könnte operative Tabellen zurückrollen, kundennahe Daten aber erhalten:
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');
}
});
Ob die Host-UI eine „Daten behalten"-Checkbox zeigt, entscheidet jeder Host für sich; der Vertrag besteht in beiden Fällen. Plugins, die nichts zu erhalten haben, können das Argument mit dem Default ignorieren — function ($keepData = false) { ... } funktioniert, ob der Host das Flag übergibt oder nicht.
Fünf Anti-Patterns
1. In die settings-, customers- oder users-Tabellen des Hosts schreiben
Verlockend, weil der Join eine Query weniger ist, aber es zementiert das Plugin für immer in das Host-Schema. Jedes Host-Upgrade, das eine Spalte umbenennt, bricht das Plugin still. Fix: Schreiben Sie in Ihre eigene Tabelle, mit FK auf die Host-Tabelle. Der Join ist günstig, die Kopplung bleibt lose.
2. $table auf Ihrem Model vergessen
Ohne explizite $table-Property pluralisiert Laravel den klein geschriebenen Klassennamen. Acmecorp\Loyalty\Models\Account löst auf accounts auf, nicht auf acmecorp_loyalty_accounts. Fix: Setzen Sie auf Plugin-Models immer protected $table = '{vendor}_{name}_'.
3. cascade-Deletes auf Host-Tabellen
Ein Admin löscht einen einzelnen Test-Kunden; Ihr Plugin verliert jede zugehörige Zeile. Fix: Verwenden Sie onDelete('set null') auf optionalen FKs, soft-deleten Sie Ihre eigenen Zeilen bei Host-Tabellen-Deletions per Queue-Job, und reservieren Sie cascade für Ihre eigenen internen Child-Tabellen.
4. Den Migrations---path in register() hartcodieren
register() läuft früh — bevor die Storage-Path-Helper des Hosts zuverlässig sind. Fix: Der activate_plugin_*-Listener gehört in boot(), wo storage_path() und Konsorten verdrahtet sind.
5. Schema- und Datenmigrationen in derselben Datei mischen
Eine Migration, die eine Spalte anlegt und dann den Wert in jede Zeile zurückschreibt, macht die Deaktivierung wacklig — das Rückrollen muss auch den Backfill umkehren. Fix: Auf zwei Timestamps aufteilen. Die Schema-Migration ist sofort reversibel; die Datenmigration ist eine separate Datei, die die Deaktivierungsroutine des Plugins erneut ausführen, überspringen oder invertieren kann.
Wie es weitergeht
Models decken die Persistenz ab; die nächste Seite ist Übersetzungen — der indirekte Flow, der Admins erlaubt, Plugin-Strings über die Languages-UI des Hosts zu editieren, ohne je den Plugin-Quellcode anzufassen. Danach behandelt Lifecycle die Vier-Zustands-Sequenz Boot/Activate/Disable/Delete in der Tiefe, und Testing deckt das phpunit.xml-Wiring für Plugin-Testsuiten ab.