Vom leeren Terminal zu einem laufenden Hello World-Plugin — in rund fünf Minuten.

Ein einziger Artisan-Befehl scaffoldet ein eigenständiges Laravel-Package unter storage/app/plugins/{vendor}/{name}/. Bevor Sie die Erfolgsmeldung zu Ende gelesen haben, ist die Datenbankzeile eingefügt, die Master-Datei aktualisiert und der Service Provider ist in der laufenden Anwendung bereits gebootet — obwohl das Plugin noch inaktiv ist. Diese Seite geht die vollständige Sequenz end-to-end durch sowie die sieben Anfängerfehler, die fast jedes Scheitern neuer Autoren erklären.

Voraussetzungen

Ein Plugin in dieser Codebase ist ein kleines Laravel-Package. Stellen Sie vor dem Scaffolden sicher, dass die AcelleMail-Host-Installation, die Sie erweitern, bereits läuft, und dass Sie auf Ihrem lokalen Rechner eine funktionierende PHP-Toolchain haben. Die folgenden CLI-Befehle gehen davon aus, dass Sie sich im Application-Root befinden (dem Verzeichnis, das die artisan-Datei enthält).

Host-Anwendung

  • AcelleMail v4.x installiert und Requests bedienend. Der Plugin-Loader ist Teil von App\Providers\AppServiceProvider — ältere 3.x-Builds haben kein Plugin::autoloadWithoutDbQuery().
  • Ein Queue-Worker, Scheduler oder Web-Request, der den Application-Root erreichen kann — der Loader läuft beim Boot, nicht on demand.
  • Schreibrechte auf storage/app/plugins/. Der Artisan-Befehl schreibt das Scaffold hierhin, nicht in vendor/.

PHP-Wissen, das einer Auffrischung wert ist

Das Plugin-System stützt sich stark auf eine Handvoll PHP- und Laravel-Grundlagen. Falls etwas davon eingerostet ist, halten Sie kurz inne und überfliegen die einschlägige Doku, bevor Sie scaffolden — ein Plugin zu debuggen, das in seiner composer.json den falschen Namespace deklariert, ist viel mühsamer, als es gleich richtig zu machen.

  • PSR-4-Autoloading. Die composer.json des Plugins mappt einen Namespace-Prefix auf das src/-Verzeichnis. AcelleMail registriert dieses Mapping beim Boot mit einem frischen Composer\Autoload\ClassLoader — die Namespace-Deklaration in jeder PHP-Datei muss daher exakt mit dem composer.json-Mapping übereinstimmen, einschließlich Großschreibung.
  • Closures und das use-Keyword. Fast jeder Hook-Listener ist eine Closure. Braucht die Closure eine äußere Variable, müssen Sie sie explizit capturen. Das zu vergessen ist die häufigste Ursache von undefined variable-Fehlern in Plugin-Code.
  • register() vs. boot() auf einem Service Provider. Laravel führt erst alle register()-Methoden aller Provider aus, dann alle boot()-Methoden. In register() aufgelistete Hooks können vor ihren Abhängigkeiten laufen; in boot() aufgelistete Hooks laufen zu spät für den Translation-Collector. Beides sind echte Footguns — siehe Sieben Anfängerfehler.
  • Eloquent, Blade, Routes, Facades. Plugin-Migrations nutzen den Standard-Schema-Builder, Plugin-Views sind ganz normale Blade-Dateien, Plugin-Routen nutzen Route::group(...). An einem Plugin ist nichts proprietär — die generierten Dateien sind Vanilla-Laravel.

Sie müssen das Plugin nicht auf Packagist veröffentlichen, kein composer install im Plugin-Ordner ausführen oder irgendetwas in der Root-composer.json des Hosts registrieren. Der Runtime-Loader erledigt jeden Schritt.

Naming-Regeln — einmal lesen, eine Stunde sparen

Jedes Plugin hat eine Identität der Form {vendor}/{name} — zum Beispiel Aurius, aix/sample, athena/evs. Diese Identität ist der kanonische Schlüssel in der Datenbanktabelle plugins, im Verzeichnis storage/app/plugins/, in der Master-Datei storage/app/plugins/index.json und in den Lifecycle-Hook-Namen (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).

Der Validator in App\Model\Plugin::init() erzwingt ein kleines, konservatives Regelwerk (kanonische Regex: ^[a-z0-9]+\/[a-z0-9]+$ mit min:2 max:32 je Seite):

  • Nur Kleinbuchstaben und Ziffern. Keine Underscores, keine Bindestriche, keine Großbuchstaben. Die frühere Vorgabe, die Underscores erlaubte, ist abgelöst — sehen Sie my_plugin in einer alten README, ist das nicht mehr gültig.
  • Zwei bis zweiunddreißig Zeichen je Seite. a/sample schlägt fehl (Vendor zu kurz); team/x schlägt fehl (Name zu kurz).
  • Genau ein Schrägstrich. Vendor und Name. Keine Verschachtelung.

Die konservative Schnittmengen-Regel stammt aus einem 2026-04-Cleanup, der Plugin::init() mit Plugin::getStoragePathByName() in Einklang brachte. Beide Validatoren stimmen jetzt auf dieselbe Regex überein — es gibt keinen Weg mehr, dass ein Name sauber scaffoldet und dann beim Laden scheitert.

Wählen Sie das Vendor-Segment sorgfältig. Der Vendor ist Teil jedes Namespace, jedes URL-Prefix in der routes.php Ihres Plugins und jedes Translation-Key, den das Plugin emittiert. Ihn später umzubenennen bedeutet Suchen-und-Ersetzen über jede Datei. acmecorp/loyalty ist eindeutig; x/loyalty ist ungültig (Vendor zu kurz); acmecorp/loyaltypoints ist okay.

Der Scaffold-Befehl

Aus dem Application-Root führen Sie aus:

php artisan plugin:init {vendor}/{name}

Für ein durchgearbeitetes Beispiel verwenden wir acmecorp/loyalty — der Rest der Seite geht von diesem Namen aus. Setzen Sie Ihren eigenen ein, wenn Sie den Befehl selbst ausführen.

$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder

Die Erfolgsmeldung wird von App\Console\Commands\InitPlugin gedruckt, einem schlanken Wrapper um die Modell-Methode App\Model\Plugin::init($name). Diese Methode erledigt alles, was der Rest dieser Seite beschreibt — Validierung, Scaffold-Kopie, Twig-Render, Datei-Rename, dann ein verketteter Aufruf von Plugin::register($name), der die Datenbankzeile einfügt und den Service Provider bootet.

Sobald der Prompt zurückkehrt, ist das Plugin als inaktives Package bereits in die laufende Anwendung geladen. In routes.php deklarierte Routen sind erreichbar, Views renderbar, und jede vom Service Provider registrierte Hook ist live. Aktivierung fügt nur das hinzu, was der Plugin-Autor an das Event activate_plugin_{vendor}/{name} gehängt hat — typischerweise einen Migrations-Lauf.

Was generiert wurde

Der Artisan-Befehl schreibt ein kleines Set Starter-Dateien in storage/app/plugins/{vendor}/{name}/, rendert darin Twig-Platzhalter und benennt die Platzhalter-Migration um. Die exakte Liste der Dateien ist in Plugin::init() hartkodiert — acht inhaltsgerenderte Dateien plus ein paar statische Assets. Keine dieser Dateien ist besonders; es ist Vanilla-Laravel, das Sie frei löschen, umbenennen oder erweitern dürfen.

Der Verzeichnisbaum auf der Platte nach Befehlsende:

storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│   └── migrations/
│       └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│   ├── lang/
│   │   └── en/
│   │       └── messages.php
│   └── views/
│       └── index.blade.php
└── src/
    ├── Controllers/
    │   └── DashboardController.php
    ├── Models/
    │   └── Setting.php
    └── ServiceProvider.php

Die acht Dateien auf einen Blick

DateiWofür sie da ist
composer.jsonRuntime-Vertrag: name, autoload.psr-4 und extra.laravel.providers sind Pflicht. Ohne diese kann der Loader weder den Namespace registrieren noch den Provider booten.
src/ServiceProvider.phpDer einzige Eintrittspunkt, den Laravel sieht. Registriert Übersetzungen in register(), dann Routen, Views, Lifecycle-Hooks und die Icon-URL in boot().
src/Controllers/DashboardController.phpEin Wegwerf-Beispiel. Liefert die mitgelieferte index.blade.php-View zurück. Frei ersetzbar.
src/Models/Setting.phpEin Eloquent-Modell, gebunden an die erste Migration des Plugins. Der Tabellenname ist als {vendor}_{name}_settings namespaced, damit Plugins auf derselben DB nicht kollidieren.
routes.phpVom Service Provider geladen. Deklariert sowohl die Icon-Serving-Route (von der Admin-Plugins-Seite verwendet) als auch eine Beispiel-Dashboard-Route plugins/{vendor}/{name}.
resources/views/index.blade.phpDie Hello-World-View, von DashboardController gerendert. Durch Ihre echte UI ersetzen.
resources/lang/en/messages.phpDie Master-Translation-Datei. Language::dump() kopiert sie zur Laufzeit nach storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — die gedumpten Dateien sind das, was die Anwendung tatsächlich liest.
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.phpDie erste Migration. Läuft nur bei Aktivierung, rollt beim Löschen zurück. Der Dateiname ist der einzige, dessen Platzhalter Twig selbst nicht rendert — Plugin::init() benennt ihn über einen separaten str_replace-Pass um.

Ein echtes Produktiv-Plugin wächst über diese Minimal-Fläche hinaus. Die kanonische Referenz innerhalb der Codebase ist storage/app/plugins/Aurius/ — acht Eloquent-Models, vierzehn Migrations, achtzehn Locales, über sechzig Views, eine Admin-Sidebar-Gruppe, eine Chatbox-UI-Bubble und eigene queue-gebundene Jobs. Das Hello World-Gerüst ist absichtlich minimal, damit Sie Teile einzeln ersetzen können, ohne jedes Subsystem auf einmal lernen zu müssen. Weitere Controller landen unter src/Controllers/, weitere Models unter src/Models/, weitere Services unter src/Services/, zusätzliche Migrations unter database/migrations/.

Was Plugin::register() im Hintergrund tat

Die Ausgabezeile sagt created & loaded, und das ist präzise. Zwischen Datei-Kopie und Erfolgsmeldung ruft Plugin::init() die Methode Plugin::register($name), die fünf eigenständige Schritte durchführt:

  1. Liest die composer.json des Plugins. Das Feld name muss exakt mit dem Verzeichnis übereinstimmen (acmecorp/loyalty) — eine Abweichung wirft eine composer name in composer.json is expected to be …-Exception.
  2. Erstellt oder aktualisiert die Zeile in der DB-Tabelle plugins. title, description und version werden aus den Composer-Metadaten gezogen. Status wird auf inactive gesetzt.
  3. Schreibt die Master-Datei. storage/app/plugins/index.json ist die Boot-Time-Registry — AppServiceProvider::boot() liest diese Datei, um bei jedem Request zu entscheiden, welche Plugins autoloaded werden, ohne die Datenbank anzufassen. Aktivierung und Deaktivierung mutieren später dieselbe Datei.
  4. Lädt den Service Provider sofort. Das boot() des Plugins läuft im aktuellen Prozess, sodass alle Routen / Views / Hooks, die es registriert, vor dem nächsten Request live sind.
  5. Materialisiert Translation-Dateien. Language::dump() liest jeden add_translation_file-Hook-Eintrag, kopiert die Master-Dateien nach storage/app/data/plugins/... und führt abschließend vendor:publish --tag=plugin --force aus, damit alle gebundelten Assets unter public/plugins/... landen.

Das mentale Modell, das es sich zu merken lohnt: „installiert" heißt schon „geladen". Aktivierung ist rein ein Status-Flip plus was der Plugin-Autor an das Activate-Event gehängt hat. Es gibt keinen separaten Routen registrieren-Schritt, den die Aktivierung auslöst — die Routen sind in dem Moment registriert, in dem plugin:init fertig ist.

Inaktive Plugins sind trotzdem geladen. Die aktuelle Implementierung von Plugin::autoloadWithoutDbQuery() lädt jedes in index.json gelistete Plugin, unabhängig vom Status. Wenn ein Feature beim Deaktivieren wirklich verschwinden soll, muss der Plugin-Autor das explizit absichern — ein Route-Middleware, das Plugin::getByName($name)->isActive() prüft und mit 404 abbricht, ist das konventionelle Muster. Das Admin-Console-Plugin der Core-Plattform ist das kanonische Beispiel.

Plugin aktivieren

Mit gescaffoldetem und inaktivem Plugin ist der nächste Schritt, es als aktiv zu markieren, damit der activate_plugin_{vendor}/{name}-Listener die Migration ausführt. Zwei Wege:

Über das Admin-UI

Melden Sie sich als Admin an, öffnen Sie /rui/admin/plugins, suchen Sie den Eintrag Loyalty und klicken Sie Activate. Die Seite rendert das Icon, das Ihre routes.php ausliefert (der Platzhalter bringt ein icon.svg im Plugin-Root mit — ersetzen Sie es durch Ihr eigenes, um den Eintrag zu branden).

Programmatisch (Tests oder Seeding)

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated

Beide Wege feuern Hook::fire('activate_plugin_acmecorp/loyalty'). Der Service Provider des Gerüsts hat im boot() einen Hook::on(...)-Listener für dieses Event registriert — der Listener ruft Artisan::call('migrate', ['--path' => ..., '--force' => true]), was die Tabelle acmecorp_loyalty_settings anlegt.

Rufen Sie /plugins/acmecorp/loyalty im Browser auf und die mitgelieferte Hello-World-Seite rendert. Das Blockquote @{{ trans('loyalty::messages.intro') }} zieht aus der gedumpten Translation-Datei unter storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.

Ihre ersten Edits

Das Gerüst ist absichtlich minimal, damit Sie Teile einzeln ersetzen können, ohne jedes Subsystem auf einmal lernen zu müssen. Eine sinnvolle Reihenfolge:

  1. composer.json aktualisieren. Setzen Sie einen echten title, description und version. Die Admin-Plugins-Seite rendert diese.
  2. Eine echte Migration hinzufügen. Legen Sie eine neue Datei unter database/migrations/ mit einem Timestamp größer als der bestehende an. Sie läuft beim nächsten Activate (oder nach einem Deactivate-Reactivate-Zyklus).
  3. Ein echtes Model hinzufügen. Das Gerüst bringt Setting als Platzhalter. Legen Sie Ihres unter src/Models/ an; namespacen Sie es als {Vendor_class}\{Name_class}\Models\YourModel. Die Klassennamen werden automatisch aus dem Lowercase-Vendor/Name abgeleitet — acmecorp wird zu Acmecorp, loyalty zu Loyalty.
  4. DashboardController ersetzen. Fügen Sie die Controller hinzu, die Ihr Feature tatsächlich braucht. Halten Sie sie dünn — schieben Sie Business-Logik in src/Services/-Klassen.
  5. Die Views ersetzen. Die mitgelieferte index.blade.php nutzt Bootstrap 5 aus einem CDN. Die meisten Plugin-Autoren werfen das raus und extenden stattdessen das Layout der Host-Anwendung.
  6. Hooks in ServiceProvider::boot() verdrahten. Siehe den Hook-System-Deep-Dive für die vier Muster. Das Gerüst demonstriert bereits EVENT (Hook::on) und BEHAVIOR (Hook::set) — REGISTRY und FILTER sind die nächsten beiden zum Lernen.

Sieben Anfängerfehler und wie man sie behebt

Fast jeder Bericht von neuen Plugin-Autoren fällt in eine dieser sieben Kategorien. Jede gründet im Code, der in App\Model\Plugin oder App\Providers\AppServiceProvider ausgeliefert wird, sodass die Symptome vorhersehbar sind.

1. Naming verstößt gegen den Validator

plugin:init wirft mit Plugin name must be in the "author/name" format oder Author name "..." is invalid. Only lowercase letters and digits are allowed. Ursache: die Regex ^[a-z0-9]+\/[a-z0-9]+$ mit min:2 max:32 je Seite weist Underscores, Bindestriche, Großbuchstaben oder Seiten kürzer als zwei Zeichen zurück.

Fix: nur Kleinbuchstaben und Ziffern verwenden — zum Beispiel acmecorp/loyalty, nicht acme_corp/loyalty-points.

2. Der Name in composer.json passt nicht zum Ordner

Nach dem Scaffolden validiert Plugin::register(), dass der name in der gerenderten composer.json mit dem Ordner unter storage/app/plugins/ übereinstimmt. Den JSON-Wert auf einen anderen Vendor oder Namen zu ändern, ohne das Verzeichnis umzubenennen, wirft Plugin name in composer.json is expected to be '{folder}', found '{json}'.

Fix: Verzeichnis und JSON im Gleichschritt umbenennen, oder plugin:init mit dem neuen Namen erneut ausführen.

3. autoload.psr-4 fehlt oder ist fehlerhaft

loadPluginByName() wirft Cannot boot plugin '{name}'. No 'autoload' found in composer.json (oder die 'autoload.psr4'-Variante), wenn der Autoload-Block entfernt oder vertippt wurde. Die Runtime braucht dieses Mapping, um den Namespace zu registrieren; ohne es lässt sich nichts in src/ instanziieren.

Fix: den gescaffoldeten autoload.psr-4-Eintrag belassen. Der dort deklarierte Namespace-Prefix (Acmecorp\Loyalty\\) muss mit der Namespace-Deklaration am Anfang jeder PHP-Datei unter src/ übereinstimmen.

4. Namespace-Deklaration passt nicht zur composer.json

Der PHP-Autoloader löst Acmecorp\Loyalty\Controllers\DashboardController nach src/Controllers/DashboardController.php auf, indem er das in composer.json deklarierte Prefix Acmecorp\Loyalty\\ abschneidet. Deklariert die Datei namespace AcmeCorp\Loyalty\Controllers (großes C in AcmeCorp), findet der Autoloader sie nicht. Symptom: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found beim ersten Request.

Fix: Die Namespace-Deklaration in jeder PHP-Datei unter src/ muss exakt die aus dem Lowercase-Vendor/Name abgeleitete Großschreibung verwenden. Für acmecorp/loyalty ist das Acmecorp\Loyalty. Plugin::makeClassNameFromString() wendet nur ucfirst an — es gibt kein intelligentes Casing.

5. Translation-Hook in boot() statt register() registriert

AppServiceProvider::boot() ruft in seiner eigenen Boot-Phase Hook::collect('add_translation_file'). Wenn das boot() eines Plugins läuft, ist diese Schleife bereits beendet — den Translation-Eintrag dort hinzuzufügen heißt, dass er nie aufgegriffen wird, und trans('loyalty::messages.intro') liefert den literalen Schlüssel.

Fix: Übersetzungen in register() registrieren, genau wie das Gerüst es tut. Die Lifecycle-Hooks für activate_plugin_* und delete_plugin_* gehören weiterhin in boot().

6. $this->loadTranslationsFrom(...) in boot() aufrufen

Ein verbreiteter Reflex ist, Laravels loadTranslationsFrom() zusätzlich zum Hook direkt aufzurufen. Weil das Plugin-boot() nach AppServiceProvider::boot läuft, überschreibt der zweite Aufruf den Namespace-Hint, der auf die gedumpten Runtime-Dateien (storage/app/data/plugins/...) zeigte, und richtet ihn neu auf die Master-Datei aus (storage/app/plugins/.../resources/lang/...). Sichtbares Symptom: Admin-Edits in der Sprachen-UI greifen zur Laufzeit nicht mehr — die gedumpten Klone werden zu Zombie-Dateien.

Fix: nur den add_translation_file-Hook verwenden. loadTranslationsFrom() nicht zusätzlich aufrufen.

7. In register() Hooks registriert, die andere Plugins oder den Kernel brauchen

register() läuft, bevor alle anderen Provider ihr register() abgeschlossen haben und lange vor jedem boot(). Code, der die Datenbank, Services eines anderen Plugins oder einen Singleton braucht, der im register() eines anderen Providers verdrahtet wird, kann mit Class not found oder Target class does not exist scheitern. Der einzige Hook, der in register() gehört, ist add_translation_file (weil er vor der collect-Schleife in AppServiceProvider::boot laufen muss).

Fix: Jeden anderen Hook in boot() stecken. Falls Sie wirklich etwas früh brauchen, gaten Sie es vorher mit app()->runningInConsole() oder isInitiated().

Schritt-für-Schritt-Checkliste

Die vollständige Sequenz, um ein funktionierendes Plugin auszuliefern, end-to-end:

  1. php artisan plugin:init {vendor}/{name} — scaffolden.
  2. composer.json editieren — echten title, description, version setzen.
  3. Migrations unter database/migrations/ schreiben.
  4. Models unter src/Models/ hinzufügen.
  5. Controller unter src/Controllers/ hinzufügen.
  6. Views unter resources/views/ hinzufügen.
  7. Routen in routes.php deklarieren.
  8. Alles in ServiceProvider::boot() verdrahten — Views, Routen, Hooks, Asset-Publishes.
  9. Als Admin anmelden → Plugins → Activate. Die Migration läuft automatisch.

Wenn etwas schiefgeht, decken zwei Debug-Einstiegspunkte fast jeden Fall ab. storage/logs/laravel.log fängt jede Exception ab, die beim Boot geworfen wird, einschließlich derer aus loadPluginByName() beim Registrieren des Autoloads. Das Feld error in jeder Zeile von storage/app/plugins/index.json zeigt den jüngsten Boot-Fehler für das Plugin und ist das, was die Admin-Plugins-Seite für die rote Error-Pille verwendet — die Datei lässt sich durch erneutes Aktivieren des Plugins (oder Löschen und Neuinstallieren) zurücksetzen.

Wie es weitergeht

Sie haben das Scaffold, den Lifecycle und die sieben Fehler, die das meiste First-Day-Debugging absichern. Die nächsten zwei Seiten liefern das mentale Modell, das die übrige Dokumentation voraussetzt:

  • Plugin-Architektur — der Boot-Time-Load-Flow, warum inaktive Plugins trotzdem autoloaded werden, der Master-Datei-Mechanismus und der Unterschied zwischen register() und boot() auf Runtime-Ebene.
  • Das Hook-System — die vier Muster (REGISTRY, EVENT, BEHAVIOR, FILTER), wann Sie zu welchem greifen, und die Konfliktsemantik, die BEHAVIOR bei Kollision werfen lässt, statt still zu überschreiben.

Wenn Sie bereit sind, ein echtes Feature-Plugin auszuliefern, sind die durchgearbeiteten Beispiele Sending-Driver (Postal MTA end-to-end) und Payment-Gateways (Paddle als regionales Gateway). Für UI-Arbeit deckt UI-Injection die Layout-/Sidebar-/Page-Slot-Hooks ab, mit denen ein Plugin eine Chatbox-Bubble oder ein Settings-Panel einhängen kann, ohne ein einziges Blade zu forken.