Vier Zustände. Vier Modellmethoden. Eine JSON-Master-Datei.

Jedes Plugin in der Host-Anwendung durchläuft vier diskrete Zustände: register (Dateien auf der Platte + Autoload + DB-Zeile), activate (Migrationen + Status-Wechsel), disable (nur Status-Wechsel — Routen und Hooks bleiben bestehen), delete (Rollback + Entfernen der DB-Zeile + Bereinigung der Master-Datei). Jeder Zustand wird durch eine einzelne Methode in app/Model/Plugin.php implementiert; jeder Übergang schreibt sowohl in die Datenbanktabelle plugins als auch in storage/app/plugins/index.json. Diese Seite geht jeden Zustand der Reihe nach durch, mit den exakten Host-seitigen Schritten.

Die vier Zustände im Überblick

Jede Plugin-Zeile trägt eine Spalte status mit einem von zwei Werten — active oder inactive. Zwei weitere Zustände sind implizit: noch nicht registriert (keine DB-Zeile, kein Master-Datei-Eintrag) und gelöscht (Plugin-Verzeichnis weg, Zeile weg, Master-Datei-Eintrag weg). Vier Übergänge bewegen das Plugin zwischen diesen Zuständen:

ÜbergangMethodeStatus vorherStatus nachherWas sich auf Platte / DB ändert
RegisterPlugin::register($name)(keine Zeile)inactiveDB-Zeile eingefügt; Master-Datei-Eintrag geschrieben; Service Provider im aktuellen Request gebootet
Activate$plugin->activate()inactiveactiveMigrationen laufen über den activate-Hook; DB-Status umgeschaltet; error in der Master-Datei zurückgesetzt
Disable$plugin->disable()activeinactiveDB-Status umgeschaltet; error in der Master-Datei zurückgesetzt. Sonst nichts.
Delete$plugin->deleteAndCleanup($keepData = false)beliebig(keine Zeile)Delete-Hook feuert (typischerweise migrate:rollback); Plugin-Ordner entfernt; DB-Zeile entfernt; Master-Datei-Eintrag entfernt

Das mentale Modell, das man im Kopf behalten sollte: register und delete verändern die Welt (Dateien auf der Platte, DB-Schema). Activate und disable schalten nur ein Flag um — die Routen, Views, Hooks und der Service-Provider-Zustand aus register bleiben bestehen. Die nächsten Abschnitte behandeln jeden Übergang der Reihe nach.

Zustand 1 — Register / Installation

Plugin::register($name) in app/Model/Plugin.php:559 ist der Einstiegspunkt. Die Methode wird automatisch am Ende von php artisan plugin:init aufgerufen und bei jedem erfolgreichen Upload über die Admin-Plugins-Seite. Die Methode erledigt der Reihe nach fünf verschiedene Dinge:

  1. Liest composer.json aus storage/app/plugins/{vendor}/{name}/ und kopiert title, description, version in das Modell. Wirft eine Exception, wenn das Composer-Feld name nicht exakt mit dem Verzeichnis übereinstimmt.
  2. Fügt die Zeile ein (oder aktualisiert sie) in der DB-Tabelle plugins mit status = inactive. Der Lookup ist firstOrNew(['name' => $name]), sodass ein erneutes Registrieren eines vorhandenen Plugins aktualisiert statt dupliziert.
  3. Schreibt die Master-Datei: storage/app/plugins/index.json erhält einen Eintrag { "name": { "status": "inactive" } }. Dies ist die Boot-Time-Registry, die der Host bei jedem Request liest, ohne in die DB zu gehen.
  4. Lädt den Service Provider sofort: $plugin->load($withServiceProvider = true) registriert das PSR-4-Präfix bei einem frischen Composer\Autoload\ClassLoader und ruft App::register() auf der Service-Provider-Klasse des Plugins auf. Sobald die Methode zurückkehrt, sind die Routen, Views und Hooks des Plugins in den laufenden Prozess eingebunden.
  5. Materialisiert Übersetzungen und publiziert Assets: Language::dump() erzeugt Laufzeit-Dateien pro Locale unter storage/app/data/plugins/{vendor}/{name}/lang/, danach kopiert artisan vendor:publish --force --tag=plugin alle gebündelten Assets nach public/plugins/{vendor}/{name}/.

Nach register ist das Plugin installiert und geladen. Es ist noch nicht aktiv — das bedeutet lediglich, dass das, was das Plugin an das activate-Event gehängt hat, noch nicht gelaufen ist. Die Routen, Views und Hook-Listener des Plugins sind bereits aktiv.

Zustand 2 — Activate

$plugin->activate() in Plugin.php:484 ist das, was der Admin-Button „Activate" aufruft. Vier geordnete Schritte:

  1. Activate-Hook feuern: Hook::fire('activate_plugin_'.$this->name). Jeder Listener, der für diesen Namen registriert ist, läuft — typischerweise der eigene Hook::on('activate_plugin_*', ...)-Listener des Plugins, der artisan migrate gegen den Migrations-Ordner des Plugins aufruft. Andere Plugins können zusätzliche Listener auf dasselbe Event registrieren.
  2. Erneute Validierung von composer.json: self::validateMetaData($config) prüft, dass die erforderlichen Keys (name, version, app_version) vorhanden und wohlgeformt sind. Fehlende Keys werfen, bevor der Status-Wechsel erfolgt.
  3. Setzt den DB-Status auf active und speichert die Zeile.
  4. Aktualisiert die Master-Datei: { "status": "active", "error": null } — der error-Reset löscht jeden vorherigen Boot-Fehler, sodass künftige Autoload-Durchläufe das Plugin als gesund betrachten.

Activation ist in der Praxis idempotent. Ein erneutes activate() auf einem bereits aktiven Plugin feuert den Hook erneut (Listener, die migrate ausführen, tun das also nochmals — die Migrations-Tabelle von Laravel filtert bereits gelaufene Dateien, sodass der zweite Aufruf ein No-op ist), validiert neu und schreibt denselben Status. Es gibt keinen speziellen „bereits aktiv"-Zweig.

Zustand 3 — Disable

$plugin->disable() in Plugin.php:136 ist die schlankste der vier Methoden. Sie tut nur Folgendes:

  1. Setzt den DB-Status auf inactive.
  2. Aktualisiert die Master-Datei mit dem neuen Status und löscht ein etwaiges error-Feld.

Das ist die gesamte Methode. Sie entlädt nichts.

Routen, die während boot() des Plugins registriert wurden, bleiben registriert. Views bleiben mountbar. Hook-Listener feuern weiterhin, wenn der Host ihren Hook feuert. Der Service Provider des Plugins ist weiterhin im Container der Anwendung geladen und wird beim nächsten Request erneut geladen, weil autoloadWithoutDbQuery() jeden Eintrag aus der Master-Datei unabhängig vom Status liest. Disable ist ein Status-Wechsel, kein Unload — Laravel selbst unterstützt das Entfernen eines bereits gebooteten Service Providers nicht.

Aus diesem Grund ist das Plugin acelle/console das kanonische Muster für „Plugin-Funktionen sollen bei Deaktivierung verschwinden": Routen laden immer, aber eine Route-Middleware namens console.active bricht mit 404 ab, wenn Plugin::getByName('acelle/console')->isActive() false zurückgibt. Der Check passiert bei jedem Request gegen den aktuellen DB-Status, sodass das Deaktivieren des Plugins seine Routen ab dem nächsten Request 404 liefern lässt.

Das Visible-Disable-Pattern in drei Schritten. (1) Definieren Sie eine Route-Middleware, die Plugin::enabled('myvendor/myplugin') prüft und bei false mit 404 abbricht. (2) Registrieren Sie sie als Middleware-Alias im boot() Ihres Service Providers. (3) Wenden Sie sie auf Ihre Route-Group in routes.php an. Jedes Plugin, das nutzerseitig sichtbare Funktionen ausliefert, sollte diesem Muster folgen — ohne das sieht „deaktiviert" aus Nutzersicht identisch zu „aktiviert" aus.

Zustand 4 — Delete

$plugin->deleteAndCleanup($keepData = false) in Plugin.php:670 ist der vollständige Teardown. Vier geordnete Schritte:

  1. Delete-Hook feuern: Hook::fire('delete_plugin_'.$name, [$keepData]). Der Skeleton-Listener ruft artisan migrate:rollback gegen den Migrations-Ordner des Plugins auf. Das $keepData-Flag wird weitergegeben, sodass der Listener entscheiden kann, Tabellen mit Kundendaten nicht zurückzurollen — siehe die Seite zu Datenbank-Modellen für das ausgearbeitete Muster.
  2. Plugin-Verzeichnis löschen: $this->deletePluginDirectory() entfernt rekursiv storage/app/plugins/{vendor}/{name}/. Nach diesem Schritt ist der PHP-Quellcode des Plugins von der Platte verschwunden.
  3. DB-Zeile löschen. Die plugins-Tabelle referenziert dieses Plugin nicht mehr.
  4. Master-Datei-Eintrag entfernen: updatePluginMasterFile($name, null) — das null ist das konventionelle Signal, den Eintrag fallen zu lassen, statt neue Felder zu mergen.

Bis der nächste Request einen frischen Prozess bootet, sind die Routen, Views und Hooks des Plugins weiterhin im Speicher geladen — der in-process Laravel-Container kennt das Konzept „Service Provider dieses Plugins deregistrieren" nicht. Der nächste Request liest die (nun geschrumpfte) Master-Datei, lädt das Plugin nicht und der In-Memory-Zustand wird mit dem Lifecycle des vorherigen Requests verworfen.

Die Master-Datei bei jedem Übergang

storage/app/plugins/index.json ist die alleinige Source of Truth zum Boot-Zeitpunkt. Jeder oben beschriebene Übergang schreibt darin. Eine hilfreiche Sicht auf den Lifecycle ist, zu beobachten, wie der Eintrag eines Plugins bei jedem Schritt aussieht:

// Before register: no entry.
{}

// After register:
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After activate:
{
  "acmecorp/loyalty": { "status": "active" }
}

// After a boot failure (sticky until cleared by activate):
{
  "acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}

// After disable (error cleared, status flipped):
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After delete: no entry.
{}

Drei Host-seitige Methoden besitzen die Datei: updatePluginMasterFile($name, $params) für Merge-Writes (übergeben Sie null als zweites Argument, um den Eintrag zu entfernen), resetPluginMasterFile(), um die Datei aus Plugin::all() neu aufzubauen, wenn sie aus dem Tritt mit der DB gerät, und getErroredPluginNames(), um jeden Eintrag zu lesen und die Namen mit nicht-leerem error zurückzugeben.

Wiederherstellung aus fehlerhaftem Zustand

Drei Fehlermodi tauchen in der Produktion auf:

1. Die Plugin-Zeile in der Master-Datei ist veraltet oder falsch

Häufig nach manuellen Edits, teilweisen Deployments oder dem Wiederherstellen eines Datenbank-Snapshots. Fix: php artisan tinker ausführen und Plugin::resetPluginMasterFile() aufrufen. Die Methode iteriert Plugin::all() aus der DB und schreibt die JSON-Datei von Grund auf neu, wobei der Status erhalten und jedes error-Feld gelöscht wird.

2. Das error-Feld eines Plugins ist gesetzt und die Admin-Plugins-Seite zeigt die rote Pille

Der Fehler ist sticky — er wird gesetzt, wenn autoloadWithoutDbQuery() einen loadPluginByName()-Aufruf in ein try/catch kapselt und der Aufruf wirft. Der Fehler bleibt, bis entweder ein erfolgreiches activate() (das error => null setzt) oder ein disable() (gleich) ausgeführt wird. Fix: das zugrundeliegende Problem beheben (fehlendes autoload.psr-4, Namespace-Mismatch, fehlende Service-Provider-Klasse), dann Activate klicken; der nächste Boot wird erfolgreich sein und der Fehler verschwindet.

3. Der Plugin-Ordner fehlt, aber der Master-Datei-Eintrag bleibt

Passiert nach einem manuellen rm -rf. Der Boot versucht weiterhin, das Plugin über den Master-Datei-Eintrag zu laden, wirft und protokolliert den Fehler. Fix: den Master-Datei-Eintrag direkt mit Plugin::updatePluginMasterFile($name, null) entfernen, oder — falls das Plugin weiterhin existieren soll — das Quellarchiv erneut hochladen und Plugin::register($name) erneut ausführen, um alles wieder zu befüllen.

Die plugin:* Console-Commands

Im Host ist ein artisan-Command enthalten: plugin:init. Es gibt keine plugin:activate-, plugin:disable- oder plugin:delete-Commands — das sind Aktionen der Admin-UI. Programmatischer Zugriff erfolgt direkt über die Modellmethoden:

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();   // → active, runs migration via activate hook
>>> $p->disable();    // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true);  // → preserve customer-facing tables

Das ist dieselbe Oberfläche, die die Admin-Plugins-Seite intern verwendet. CI-Skripte, Seeder und Integrationstests greifen alle direkt auf diese Methoden zu. Der Testing-Deep-Dive behandelt das Muster auf Test-Suite-Ebene.

Zustandsübergänge in einem Diagramm

          ┌─────────────────────┐
          │   not registered    │  (no row, no master-file entry)
          └──────────┬──────────┘
                     │ Plugin::register($name)
                     │  ├─ writes DB row (status=inactive)
                     │  ├─ writes master file
                     │  ├─ loads service provider in-process
                     │  └─ Language::dump() + vendor:publish
                     ▼
          ┌─────────────────────┐
   ┌────▶ │      inactive       │ ◀───┐
   │      └──────────┬──────────┘     │
   │                 │                │
   │       activate()│                │ disable()
   │                 │                │   ├─ status=inactive
   │                 │                │   └─ master file updated
   │                 ▼                │
   │      ┌─────────────────────┐     │
   │      │       active        │ ────┘
   │      └──────────┬──────────┘
   │                 │
   │     deleteAndCleanup($keepData)
   │                 │  ├─ fires delete hook (rollback unless $keepData)
   │                 │  ├─ removes plugin folder
   │                 │  ├─ deletes DB row
   │                 │  └─ removes master-file entry
   │                 ▼
   │      ┌─────────────────────┐
   └──────│   not registered    │
          └─────────────────────┘
          (cycle: register again to re-install)

Fünf Anti-Patterns

1. Disable so behandeln, als würde es das Plugin entladen

Routen registrieren sich weiterhin, Hooks feuern weiterhin, Views bleiben mountbar. Fix: Nutzersichtbare Funktionen mit einer Plugin::enabled(...)-Middleware oder einem Inline-Check absichern, genau wie acelle/console.

2. Die Master-Datei in Produktion manuell editieren

Das JSON ist leicht zu beschädigen. Fix: Rufen Sie Plugin::updatePluginMasterFile() oder Plugin::resetPluginMasterFile() über tinker auf — beide validieren.

3. rm -rf storage/app/plugins/{vendor}/{name} ohne den Master-Eintrag zu entfernen

Der Boot versucht weiterhin, das fehlende Plugin zu laden und protokolliert den Fehler. Fix: Eine Ordner-Entfernung immer mit Plugin::updatePluginMasterFile($name, null) kombinieren, oder deleteAndCleanup() verwenden, das beides erledigt.

4. activate() aus dem boot() eines Service Providers heraus aufrufen

Die Boot-Phase läuft einmal pro Prozess; ein dortiger activate()-Aufruf feuert den activate-Hook bei jedem Request. Die Migration läuft jedes Mal (idempotent — aber teuer), und die Side-Effect-Listener feuern ebenfalls. Fix: Activation ist eine Aktion der Admin-UI, niemals ein Boot-Zeit-Seiteneffekt.

5. Vergessen, dass register vor activate passiert

Einige Plugins versuchen, Standarddaten über einen activate-Hook-Listener zu seeden und referenzieren Eloquent-Modelle, die von den eigenen Migrationen des Plugins abhängen — aber die Migrationen sind beim ersten activate noch nicht gelaufen. Fix: Der Migrations-Listener läuft während activate, vor jedem anderen Hook::on('activate_plugin_*')-Listener, der die neuen Tabellen referenzieren könnte. Ordnen Sie Ihre Registrierungen so, dass die Migration zuerst läuft (so ist es im Skeleton — lassen Sie es dabei).

Wie es weitergeht

Lifecycle deckt das Wann ab; Testing deckt das Verifizieren ab. Die nächste Seite behandelt die Testsuite-Registrierung in der phpunit.xml, das Basisklassen-Muster PluginTestCase, Assertions auf Hooks unter Test und den activate-test-delete CI-Zyklus.