Was ein Plugin hier ist
Ein Plugin ist ein eigenständiges Laravel-Package, das unter storage/app/plugins/{vendor}/{name}/ innerhalb der AcelleMail-Host-Installation lebt. Es bringt seine eigene composer.json, seinen eigenen PSR-4-Namespace, seinen eigenen Service Provider, eigene Routen, Views, Migrations und Übersetzungen mit. Es ist exakt wie eine winzige Laravel-Anwendung aufgebaut — bis auf einen entscheidenden Unterschied.
Die Host-Anwendung installiert das Plugin nicht über den Root-Composer-Autoloader. Es gibt keinen composer require-Schritt, kein vendor/{vendor}/{name}/-Verzeichnis, keinen Eintrag in composer.lock. Stattdessen erledigt die Anwendung bei jedem Boot Folgendes von sich aus:
- Liest die
composer.json jedes Plugins.
- Registriert den dort deklarierten PSR-4-Namespace mit einer frischen
Composer\Autoload\ClassLoader-Instanz.
- Ruft
App::register(...) auf die unter extra.laravel.providers gelisteten Service Provider.
Die Entscheidung war bewusst. Plugins als Composer-installierte Packages zu behandeln, hätte die composer.json der Host-Anwendung zum beweglichen Ziel gemacht — jede Installation, Deaktivierung oder jedes Upgrade hätte die Lockfile mutiert. Der Runtime-Loader hält den Dependency-Graph des Hosts stabil: Plugins bringen ihre eigenen Metadaten mit, und der Host kann sie scannen, ignorieren oder neu ordnen, ohne vendor/ anzufassen.
Fünf Dateien, die das gesamte System steuern
Fast jedes Verhalten im Plugin-Lifecycle ist in fünf Dateien der Host-Anwendung implementiert. Den Quellcode davon zu lesen ist der schnellste Weg, alles in dieser Dokumentation zu bestätigen:
| Datei | Verantwortung |
app/Console/Commands/InitPlugin.php | Der CLI-Eintrittspunkt für php artisan plugin:init. Schlanker Wrapper um Plugin::init($name). |
app/Model/Plugin.php | Der gesamte Lifecycle: scaffold, register, load, activate, disable, delete, plus die Master-Datei-Maschinerie. |
app/Library/HookManager.php | Die Injection-Primitive, mit denen Plugins das Core-Verhalten erweitern — REGISTRY, EVENT, BEHAVIOR, FILTER. Rund 160 Zeilen, keine Abhängigkeiten. |
app/Providers/AppServiceProvider.php | Plugin-Autoload + Translation-Registrierung zur Boot-Zeit. Die einzige Call-Site, die Plugins in die laufende Anwendung einbindet. |
app/Model/Language.php | Materialisiert Plugin-Translation-Dateien nach storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. Die Indirektion, die Admins erlaubt, Übersetzungen via Sprachen-UI zu editieren, ohne Plugin-Quelldateien anzufassen. |
Zusammen umfassen sie deutlich unter dreitausend Zeilen Host-seitigen Codes. Das Plugin-System ist absichtlich klein — jede Einschränkung, die ein Plugin hat, kommt aus einer dieser fünf Dateien, und es gibt keinen anderen Ort, an dem nachzuschauen wäre.
Der Boot-and-Load-Flow
Jeder Request, jeder Queue-Worker, jeder Scheduler-Tick und jeder Artisan-Befehl durchläuft dieselbe Boot-Sequenz. Der plugin-relevante Ausschnitt sieht so aus:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
Zwei Implementierungsdetails dieser Sequenz haben übergroße Folgen für Plugin-Autoren:
1. Die Boot-Time-Discovery fragt nie die Datenbank ab
Die Liste der zu ladenden Plugins kommt aus storage/app/plugins/index.json, nicht aus der DB-Tabelle plugins. Service Provider dürfen die Datenbank nicht sicher abfragen — in dem Moment, in dem AppServiceProvider::boot() läuft, gibt es die Verbindung möglicherweise noch nicht (CLI-Befehle wie artisan db:create), oder das Schema ist noch nicht migriert (CI-Test-Setup). Die Boot-Time-Registry in einer JSON-Datei abzulegen, umgeht das Problem vollständig.
Die DB-Tabelle existiert weiter. Sie speichert denselben status wie die JSON-Datei, plus nutzersichtbare Metadaten wie title, description und version. Die Admin-Plugins-Seite liest aus der DB; der Boot-Loader liest aus der JSON. Beide werden von Plugin::register(), activate() und disable() synchron gehalten — jede Statusänderung schreibt in beide Stores.
2. autoloadWithoutDbQuery() lädt derzeit jedes Plugin im Index — inklusive inaktiver
Die aktuelle Implementierung iteriert jeden Eintrag in index.json und ruft loadPluginByName darauf, unabhängig vom status. Der Grund ist pragmatisch: Auch ein inaktives Plugin braucht seine Routen registriert (damit Admin-Seiten weiter funktionieren, wenn ein Admin „deaktivieren" klickt, ohne sofort neu zu laden), und es braucht seine Übersetzungen verfügbar (damit die Dump-Klone nicht veralten).
Die Konsequenz ist, dass „inaktiv" im AcelleMail-Plugin-System nicht dasselbe ist wie „entladen". Der nächste Abschnitt schärft die Unterscheidung.
Der composer.json-Vertrag
Die composer.json eines Plugins ist nicht nur Metadaten — sie ist der Runtime-Vertrag, von dem der Loader abhängt. Die Schlüssel, auf die es ankommt:
| Schlüssel | Zweck |
name | Kanonische Plugin-ID. Muss exakt mit dem Verzeichnis unter storage/app/plugins/ übereinstimmen. Plugin::register() wirft, wenn sie auseinanderlaufen. |
autoload.psr-4 | Mappt den Namespace-Prefix des Plugins auf src/. Pflicht — ohne ihn wirft loadPluginByName() und das Plugin kann nicht booten. |
extra.laravel.providers | Array vollqualifizierter Klassennamen. Der Loader ruft App::register() auf jeden davon. Pflicht, wenn das Plugin Routen, Views, Hooks oder sonst irgendetwas registrieren will. |
extra.setting-route | Der controller@method-Eintrag, auf den die Admin-Plugins-Seite den „Settings"-Button des Plugins verlinkt. Optional — Plugins ohne Konfiguration können ihn weglassen. |
title, description, version | Wird in der Admin-Plugins-Listung dargestellt. title ist Pflicht; die anderen fallen auf Defaults zurück. |
Das Autoload-Mapping wird zur Laufzeit registriert, nicht beim Install. Sie müssen nach dem Editieren der PSR-4-Map des Plugins kein composer dump-autoload ausführen — der Host instanziiert bei jedem Request einen frischen ClassLoader und liest die Datei neu. Das ist auch der Grund, warum das Umbenennen des Namespace eines Plugins nicht mehr als Suchen-und-Ersetzen plus einen Request an den Host erfordert.
Die Master-Datei (storage/app/plugins/index.json)
Die Master-Datei ist ein flaches JSON-Objekt, mit dem Plugin-Namen als Schlüssel. Jeder Eintrag speichert mindestens einen status, plus optional einen error-String, wenn der jüngste Boot-Versuch fehlschlug. Eine typische Datei sieht so aus:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
Drei Host-Methoden besitzen diese Datei. Jede Statusänderung geht durch eine davon:
Plugin::updatePluginMasterFile($name, $params) — merge-schreibt den Eintrag eines einzelnen Plugins. Übergeben Sie null als zweites Argument, um den Eintrag vollständig zu entfernen (Delete-Pfad).
Plugin::resetPluginMasterFile() — baut die Datei von Grund auf neu, indem über Plugin::all() iteriert wird. Wird als Recovery genutzt, wenn die JSON korrumpiert oder out of sync mit der DB ist.
Plugin::getErroredPluginNames() — liest jeden Eintrag, gibt die Namen mit nicht-leerem error zurück. Die Admin-Plugins-Listung nutzt das, um defekte Plugins ans Ende zu schieben und die rote Error-Pille einzublenden.
Der error-Schlüssel wird gesetzt, wenn autoloadWithoutDbQuery() einen loadPluginByName()-Aufruf in try/catch wickelt und der Aufruf wirft. Die Exception-Nachricht wird festgehalten, damit das Admin-UI etwas anzuzeigen hat, ohne das Scheitern erneut auszulösen. Das erneute Aktivieren eines sauberen Plugins räumt das Feld automatisch ab.
Die Master-Datei ist zur Boot-Zeit die Single Source of Truth. Wenn Sie je aus einem hängenden Plugin recovern müssen (Admin-UI down, Datenbank offline), editieren Sie storage/app/plugins/index.json direkt. Der nächste Request liest den aktualisierten Zustand und verhält sich entsprechend. Die DB-Zeile ist die langfristige Metadaten-Ablage; die JSON-Datei ist die Runtime-Registry.
Timing von register() vs. boot()
Laravel führt zunächst die register()-Methode jedes Service Providers in Registrierungsreihenfolge aus, bevor irgendein boot() aufgerufen wird. Das ist Laravel-Allgemeinwissen — hat aber im Plugin-System direkte Konsequenzen.
Was in register() gehört
- Konstanten und Bindings — diese müssen existieren, bevor das eigene
boot() des Hosts läuft.
- Der
add_translation_file-Hook — und nur dieser Hook. Das AppServiceProvider::boot() des Hosts ruft in seiner eigenen Boot-Phase Hook::collect('add_translation_file'). Wenn das boot() eines Plugins läuft, ist diese Schleife bereits beendet. Registriert ein Plugin seinen Translation-Eintrag in boot(), wird er nie aufgegriffen — und trans('myname::messages.intro') liefert den literalen Schlüssel.
Was in boot() gehört
- Routen und Views —
$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
- Asset-Publishes —
$this->publishes([...], 'plugin').
- Lifecycle-Event-Listener —
Hook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
- Die Icon-URL —
Hook::set('icon_url_{vendor}/{name}', ...).
- Jeder andere Hook — REGISTRY
add, EVENT on, BEHAVIOR set, FILTER modify. Alles, was von Container-Bindings, Config oder anderen Plugins abhängt.
Rufen Sie $this->loadTranslationsFrom(...) nicht im boot() Ihres Plugins auf. Der Host hat den Namespace bereits über den add_translation_file-Hook verdrahtet und ihn auf die gedumpten Runtime-Dateien unter storage/app/data/plugins/... ausgerichtet. Ein zweites loadTranslationsFrom aus dem boot() Ihres Plugins überschreibt den Hint des Hosts und richtet den Namespace neu auf die Master-Datei unter resources/lang/... aus. Sichtbares Symptom: Admin-Edits in der Sprachen-UI greifen zur Laufzeit nicht mehr — die gedumpten Klone werden zu Zombie-Dateien. Nur den Hook verwenden.
Warum inaktive Plugins die App trotzdem beeinflussen
Der autoloadWithoutDbQuery()-Aufruf beim Boot lädt jedes in index.json gelistete Plugin unabhängig vom Status. Ein „inaktives" Plugin hat also weiterhin all dies beim Host registriert:
- Seine Routen — deklariert per
$this->loadRoutesFrom(...) in boot().
- Seine Views — deklariert per
$this->loadViewsFrom(...).
- Seine Middleware-Aliases — registriert über die Standard-Laravel-APIs.
- Seine Hook-Listener — jedes
Hook::add, Hook::on, Hook::modify, Hook::set feuert weiterhin.
- Seine UI-Fragmente — alles, was über
layout.head.assets, layout.body.before_close, admin.sidebar.groups oder Page-Slot-REGISTRY-Hooks beigesteuert wurde, erscheint weiterhin.
Was die Aktivierung tatsächlich hinzufügt, ist nur das, was der Plugin-Autor an activate_plugin_{vendor}/{name} gehängt hat. Der Listener des Gerüsts führt die Migration aus. Es gibt keinen impliziten „Routen registrieren, wenn aktiv"- oder „Routen entfernen, wenn inaktiv"-Schritt — die Routen wurden in dem Moment registriert, in dem die Anwendung gebootet hat.
Wenn ein Feature beim Deaktivieren wirklich verschwinden soll, muss der Plugin-Autor das explizit absichern. Das konventionelle Muster lebt in storage/app/plugins/acelle/console: Routen laden immer, aber ein Route-Middleware namens console.active bricht mit 404 ab, wenn Plugin::getByName('acelle/console')->isActive() false liefert. Übernehmen Sie dieses Muster, wenn „deaktiviert" auch „nicht erreichbar" heißen soll.
Dasselbe gilt für UI-Hooks. Soll eine Chatbox-Bubble, die über layout.body.before_close injiziert wird, bei inaktivem Plugin ausgeblendet werden, muss der Closure-Body zuerst Plugin::enabled('myvendor/myplugin') prüfen und bei false null zurückgeben. Der Host filtert falsy Returns automatisch heraus, bevor gerendert wird.
Lifecycle: register / activate / disable / delete
Vier Zustände, vier Host-Methoden. Jede ist präzise darin, was sie ändert und was nicht.
Register / Install
Plugin::register($name) ist der Eintrittspunkt — wird am Ende von plugin:init sowie bei jedem erfolgreichen Upload über das Admin-UI automatisch aufgerufen. Die fünf Schritte sind:
- Liest
composer.json, kopiert title / description / version ins Modell.
- Fügt die Zeile in
plugins ein oder aktualisiert sie mit status = inactive.
- Schreibt
storage/app/plugins/index.json mit { "name": { "status": "inactive" } }.
- Ruft
Plugin::load($withServiceProvider = true) — registriert den PSR-4-Prefix und bootet den Service Provider sofort, sodass alle Routen / Views / Hooks im aktuellen Prozess live werden.
- Ruft
Language::dump(), um Translation-Dateien zu materialisieren, dann vendor:publish --tag=plugin --force, um alle gebundelten Assets nach public/plugins/... zu kopieren.
Nach Register ist das Plugin installiert und geladen. Es fehlt nur, was das Plugin selbst an sein activate-Event gehängt hat — typischerweise einen Migrations-Lauf.
Activate
$plugin->activate() wird vom „Activate"-Button im Admin-UI aufgerufen (und aus Tests / Seedern, die das Modell direkt nutzen). Es erledigt vier Dinge in dieser Reihenfolge:
- Feuert
Hook::fire('activate_plugin_'.$name). Der Listener des Gerüsts führt artisan migrate gegen storage/app/plugins/{vendor}/{name}/database/migrations aus. Andere Plugins können weitere Listener registrieren — REGISTRY-Verhalten, jeder Listener feuert.
- Re-validiert die
composer.json des Plugins gegen die vom Host geforderte Schlüsselliste (name, version, app_version).
- Setzt den DB-
status auf active.
- Aktualisiert die Master-Datei:
{ "status": "active", "error": null } — räumt einen vorherigen Boot-Fehler ab.
Disable
$plugin->disable() macht nur:
- Setzt den DB-
status auf inactive.
- Aktualisiert die Master-Datei mit dem neuen Status und räumt einen aufgezeichneten
error ab.
Es entlädt weder Routen, Views, Service Provider, Hook-Listener noch sonst etwas, das beim Boot registriert wurde. Der Host kennt das Konzept „Service Provider deregistrieren" nicht — Laravel selbst unterstützt das nicht. Disable ist ein Status-Flip, kein Unload.
Delete
$plugin->deleteAndCleanup($keepData = false) geht den vollständigen Teardown:
- Feuert
Hook::fire('delete_plugin_'.$name, [$keepData]). Der Listener des Gerüsts führt migrate:rollback aus; $keepData = true kann das überspringen für Plugins, deren Daten der Admin erhalten möchte.
- Löscht das Plugin-Verzeichnis unter
storage/app/plugins/... rekursiv.
- Löscht die Zeile aus der DB-Tabelle
plugins.
- Entfernt den Eintrag aus der Master-Datei.
Bis der nächste Request einen frischen Prozess bootet, ist der Service Provider des Plugins weiterhin im Speicher geladen. Der nächste Request liest die (jetzt geschrumpfte) Master-Datei, lädt das Plugin nicht und der In-Process-Zustand wird mit dem Request-Lifecycle verworfen.
Zwei Injection-Schichten
Ein Plugin beeinflusst die Host-Anwendung über zwei parallele Schichten. Sie zu unterscheiden ist das, was die restliche Doku sauber auf den Code abbildet.
Schicht 1 — Laravel-Registrierung
Über den Service Provider nutzt ein Plugin die Standard-Laravel-Container-APIs, um die Anwendung zu erweitern:
$this->loadRoutesFrom(__DIR__ . '/../routes.php') — ergänzt die HTTP-Fläche des Plugins.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — exponiert Blade-Views unter dem Namespace myname::view.
$this->publishes([...], 'plugin') — kopiert gebundelte Assets beim Install in das Host-public/plugins/{vendor}/{name}/.
- Middleware-Aliases, Container-Bindings, Console Commands, Scheduled Tasks, Queue-Listener — alles, was Laravel selbst unterstützt.
Schicht 2 — Hook-basierte Injection
Der Host ruft an sorgfältig gewählten Erweiterungspunkten in die App\Library\HookManager-Primitive. Plugins registrieren Listener an diesen Punkten, um teilzunehmen. Es gibt exakt vier Muster: REGISTRY, EVENT, BEHAVIOR, FILTER. Der nächste Deep Dive — Das Hook-System — deckt jedes davon vollständig ab.
Zwei Dinge, die jetzt schon zu wissen sind: (1) Jeder Hook, den der Host feuert, ist ein stabiler Vertrag — einmal veröffentlicht, ändern sich Name und Signatur zwischen Releases nicht. (2) BEHAVIOR ist exklusiv — versuchen zwei Plugins, denselben Namen mit Hook::set zu belegen, wirft der zweite Aufruf sofort. Es gibt kein stilles Override; Konflikte zeigen sich beim Boot, nicht in Produktion.
Die Codebase liefert drei Layout-Level-REGISTRY-Hooks, die fast jedes UI-erweiternde Plugin nutzt:
| Hook-Schlüssel | Wo er feuert | Genutzt für |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php, vor @yield('head') | CSS / JS, das vor dem Seiteninhalt laden muss (Chatbox-Styles, Sparkle-Popover-Skripte) |
layout.body.before_close | Dieselben Layouts, direkt vor </body> | Floating Widgets — Chatbox-Bubble, Modals, Sparkle-Popover |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Vom Plugin beigesteuerte Admin-Sidebar-Sektionen |
Alle drei folgen demselben Idiom: Jeder Callback gibt gerenderten HTML oder null zurück; der Host iteriert mit array_filter und emittiert jedes Fragment via {!! !!}. null zurückzugeben ist die konventionelle Art, einen Beitrag per Feature-Flag oder Plugin-Status zu gaten, ohne zu werfen.
Translation-Flow zur Laufzeit
Plugin-Übersetzungen werden nicht direkt aus dem resources/lang/-Ordner des Plugins ausgeliefert. Der Flow ist indirekt, und genau diese Indirektion lässt Admins Übersetzungen über die Sprachen-UI des Hosts editieren, ohne sich an die Quelldateien des Plugins zu binden. Die verifizierte Sequenz:
- Das
register() des Plugins steuert einen Hook::add('add_translation_file', ...)-Eintrag bei, der auf storage/app/data/plugins/{vendor}/{name}/lang/ zeigt.
- Das
AppServiceProvider::boot() des Hosts sammelt alle solche Einträge ein und ruft $this->loadTranslationsFrom() auf jeden davon.
- Bei jedem
Plugin::register() ruft der Host Language::dump().
Language::dump() liest die Master-Datei des Plugins unter resources/lang/en/messages.php und kopiert sie für jede unterstützte Locale nach storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php.
- Die Sprachen-Admin-UI editiert die gedumpten Runtime-Dateien. Die Quell-Master-Datei des Plugins bleibt unangetastet.
Zwei Pfade, die man sich merken sollte:
- Master-Datei (die editieren Sie in der Quelle):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- Runtime-Dateien (auto-generiert, das liest die App tatsächlich):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
Wenn Sie die Master-Datei editieren, führen Sie php artisan translation:upgrade aus, um den Master in alle Locale-Runtime-Dateien zurückzusyncen (während Admin-Edits über die Sprachen-UI erhalten bleiben). Die vollständige Mechanik — Master vs. Runtime, Upgrade-Semantik, Per-Locale-Fallback — bekommt einen eigenen Deep Dive unter Übersetzungen.
Was das für Plugin-Autoren bedeutet
Fünf Regeln fallen aus der obigen Architektur. Sie zu verinnerlichen verwandelt den Großteil der oberflächlichen Komplexität in der übrigen Doku in einen Abgleich gegen diese Liste.
- Behandeln Sie
boot() als die Registrierungsphase. Routen, Views, Hooks, Lifecycle-Listener — fast alles gehört hierhin. Das einzige, was in register() gehört, ist der add_translation_file-Hook (weil der Host ihn vor dem boot() jedes Plugins einsammelt).
- Inaktiv heißt nicht entladen. Alles, was Sie beim Boot registrieren, ist live, unabhängig vom
active/inactive-Status. Soll ein Feature beim Deaktivieren wirklich verschwinden, gaten Sie es explizit mit einem Route-Middleware oder einem Plugin::enabled(...)-Check innerhalb der Hook-Closure.
- Editieren Sie Übersetzungen über die Master-Datei, nie direkt über
loadTranslationsFrom(). Die gedumpten Klone unter storage/app/data/plugins/... sind das, was die Runtime liest. Den Namespace selbst auf das Master-Verzeichnis zu richten, überschreibt den Hint des Hosts und bricht die Sprachen-UI.
- Halten Sie
composer.json schlank und stabil. Der Runtime-Loader liest sie bei jedem Request. autoload.psr-4, extra.laravel.providers, name, title sind die Schlüssel, die der Host tatsächlich nutzt. Zusätzliche Schlüssel sind erlaubt, bewirken aber nichts.
- Die vier Hook-Muster sind der einzige Vertrag. Wenn Sie sich dabei ertappen, eine Core-Klasse „importieren" zu wollen, um sie zu erweitern — innehalten. Der Plugin-Vertrag ist einseitig: Der Core deklariert Hooks, Plugins reagieren. Existiert der Erweiterungspunkt, den Sie brauchen, noch nicht als Hook, ist der richtige Schritt, ein Issue gegen den Host zu öffnen, nicht
use Acelle\Model\Customer aus dem Controller Ihres Plugins.
Wie es weitergeht
Sie haben die Architektur. Zwei Seiten verwandeln dieses mentale Modell in die Alltags-APIs, zu denen Sie greifen werden:
- Das Hook-System — die vier Muster in der Tiefe, mit echten Call-Sites aus dem Core gegrept. Die Konfliktsemantik, wann welches Muster, und die Anti-Patterns, die richtig aussehen, aber in Produktion brechen.
- UI-Injection — die oben genannten Layout-Level-Hooks plus der
page.{controller}.{action}.{slot}-Vertrag, mit dem ein Plugin eine Card in eine bestehende Seite injizieren kann, ohne ein einziges Blade zu forken.
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 eine komplette Leseverständnisübung führt das Aurius-Showcase durch das kanonische komplexe Plugin: acht Models, vierzehn Migrations, achtzehn Locales und jede in Produktion eingesetzte Hook-Fläche.