Plugin-isolierte Tabellen. Vendor-präfixierte Namen. Automatische Migration beim Aktivieren.

Die Daten eines Plugins leben in eigenen Tabellen — in derselben Datenbank, die die Host-Anwendung nutzt — aber mit Namen, die durch die {vendor}_{name}-Identität des Plugins präfixiert sind, sodass zwei Plugins nie kollidieren können. Migrationen liegen innerhalb des Plugin-Ordners, laufen, wenn der Admin auf Activate klickt, und werden zurückgerollt, wenn der Admin auf Delete klickt. Es gibt einen kanonischen Schalter — das $keepData-Flag — für Plugins, deren Nutzerdaten der Admin über Re-Installs hinweg erhalten möchte.

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:

  1. 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.
  2. 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\Settingsettings) 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 istonDelete('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:

DateinameWas angelegt / verändert wird
2026_04_28_000001_create_ai_conversations_table.phpMulti-Turn-Chat-Sessions — uid, customer_id-FK, status-Enum, Token-/Kosten-Rollups
2026_04_28_000002_create_ai_messages_table.phpEinzelner User- / Agent-Turn — Rolle, Content-JSON, Tool-Call-FK, Latenz, verwendetes Modell
2026_04_28_000003_create_ai_requests_table.phpEine Zeile pro Upstream-API-Aufruf — Engine, Prompt-Hash, Latenz, Kosten, Fehler
2026_04_28_000004_create_ai_tool_calls_table.phpFunction-Call-Aufrufe, ausgelöst durch einen Agent-Turn — Tool-Name, Input-/Output-JSON
2026_04_28_000005_create_ai_feedback_table.phpDaumen hoch/runter + Freitext-Feedback pro Nachricht + pro Konversation
2026_04_28_000006_create_ai_raw_blobs_table.phpOriginal-Rohantworten der Provider, für Replay / Audit aufbewahrt
2026_04_28_000007_create_ai_daily_rollup_table.phpTagesaggregate für das Admin-Dashboard — Token-Summen, Kosten, Fehlerquote
2026_04_29_000001_add_client_message_id_to_ai_messages.phpSpalte für Cross-Tab-Dedup — additiv, keine Defaults
2026_04_30_000002_add_source_to_ai_tool_calls.phpHält fest, ob ein Tool-Call vom Agent oder von der Support-Route kam
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpULID-/UUID-Breitenfix — spaltenverändernde Migration
2026_05_02_200000_create_ai_tool_undo_records_table.phpVerfolgt reversible Tool-Aktionen für das „Letzte Aktion rückgängig"-Feature
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpFügt eine JSON-Spalte für bereinigte URL-Telemetrie hinzu
2026_05_04_000001_create_ai_settings_table.phpPlugin-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.