Richtlinien
Alle verbindlichen Regeln an einem Ort. Jede Zeile nennt die Regel und die Folge, wenn du sie brichst — denn fast alle diese Fehler sind still. Sie werfen keine Ausnahme, sie tun einfach nicht, was du erwartest.
Der vollständige Durchlauf zeigt jede dieser Regeln im Einsatz.
Wie diese Seite zu lesen ist
Drei Stufen:
- Hart erzwungen — der Bau, ein Wächter oder das Tor bricht ab. Du kommst nicht daran vorbei.
- Still — nichts bricht, aber das Ergebnis ist falsch. Das sind die teuren.
- Konvention — nicht erzwungen, aber im Bestand einheitlich. Abweichen kostet Nachfragen.
Benennung und Identität
| Regel | Stufe | Folge bei Verstoß |
|---|
Plugin-Schlüssel als <hersteller>.<name>, klein, mit Punkt | Konvention | Kollision mit einem anderen Plugin |
getPluginKey() im Descriptor, name in plugin.json und extra.octibiz.pluginKey in composer.json sind identisch | Hart | Der Installer bricht ab |
getModuleFlag() heißt module.<name-mit-bindestrich> und steht genauso in plugin.json | Hart | Der Installer bricht ab |
getProviderKey() in jedem Provider ist der Plugin-Schlüssel | Still | Beim Entfernen bleiben Katalog-Waisen stehen |
| Adress-Segmente tragen dein Präfix und beanspruchen kein Kern-Segment | Hart | Abbruch vor jedem Seiteneffekt |
Rechte heißen <plugin>.<objekt>.<verb>, üblich sind view und manage | Konvention | — |
Tabellen tragen ein fachliches Präfix, kein Hersteller-Präfix: acme_shipment, nicht acme_plugin_shipment | Konvention | — |
Die Version steht gleich in AcmeShippingDescriptor::VERSION, composer.json, plugin.json und im obersten CHANGELOG.md-Abschnitt | Hart | Ein Prüfer schlägt an |
Der Descriptor
| Regel | Stufe | Folge bei Verstoß |
|---|
PluginDescriptorInterface ist die einzige Pflicht-Implementierung | Hart | Ohne ihn existiert das Plugin nicht |
Jedes neue API-Präfix steht in getRouteSegments() | Still | Für dieses Segment greift keine Modul-Sperre — die Route bleibt erreichbar, obwohl das Modul aus ist |
Jede Kopplung an ein anderes Plugin steht in getDependencies() | Still | Der Trockenlauf vor dem Entfernen meldet fälschlich „keine Abhängigen" |
spiVersion in plugin.json nennt die Vertragsfläche, gegen die du gebaut hast | Hart | Fehlt sie, Installation nur mit Warnung; falsche Hauptversion bricht ab |
Das Bundle
| Regel | Stufe | Folge bei Verstoß |
|---|
Kein services.yaml im Plugin | Konvention | Kein ausgeliefertes Plugin hat eines |
Überschreibst du prependExtension(), ruf parent:: auf | Still | Entity-Anmeldung, Migrationspfad und Vorlagen fallen lautlos aus. Auf einer bestehenden Entwickler-Datenbank unsichtbar, weil die Tabellen schon da sind — erst eine Frischinstallation legt keine einzige mehr an |
Ein Kern-Dienst wird über serviceAliases() ersetzt, nicht über #[AsAlias] | Still | Die Konfiguration des Kerns gewinnt beim Zusammenführen, der Ersatz bleibt wirkungslos |
| Ein ersetzter Kern-Dienst fragt zu Beginn, ob sein Plugin aktiv ist | Still | Er wirkt weiter, obwohl das Modul aus ist. Das Umschalten baut den Container nicht neu |
Datenmodell
| Regel | Stufe | Folge bei Verstoß |
|---|
Ganzzahliger Primärschlüssel plus HasUlid; der API-Bezeichner ist immer die ULID | Still | Interne Zählstände nach außen; ratbare Kennungen |
Timestampable und, wo fachlich sinnvoll, SoftDeletable | Konvention | — |
BrandScoped für Belege und Vorgänge, nicht für Stammdaten | Konvention | — |
| Geld immer in Cent als Ganzzahl | Konvention | Rundungsfehler |
| Status sind Daten in einer Zeichenkettenspalte, kein Aufzählungstyp im Code | Konvention | Der Status lässt sich nie konfigurieren |
| Keine Fremdschlüssel über Plugin-Grenzen | Still | Das Entfernen des einen Plugins zerlegt das andere |
| Nie roh in die Tabelle eines fremden Plugins schreiben | Still | Real passiert: Nach dem Entfernen mit Datenlöschung beantwortete die Projektliste jeden Aufruf mit einem Serverfehler |
Migrationen
| Regel | Stufe | Folge bei Verstoß |
|---|
| Rein additiv, kein Rückwärtsschritt auf einer laufenden Datenbank | Konvention | Datenverlust beim Zurückrollen |
Nur Schema, kein einziges INSERT | Still | Eine zweite Wahrheit, die beim nächsten Abgleich auseinanderläuft |
isTransactional(): bool { return false; } | Konvention | Entspricht dem Bestand |
Eigener Namensraum Acme\Shipping\Migrations | Hart | Wird sonst nicht als Migrationspfad erkannt |
Ein Plugin, das nur migriert und keine Verträge liefert, hat nach der Installation leere Tabellen.
API und Rechte
| Regel | Stufe | Folge bei Verstoß |
|---|
API-Ressourcen sind eigene Klassen in src/ApiResource/, nicht die Entity | Konvention | Interne Felder werden nach außen sichtbar |
Sammel-Operation: is_granted('recht') ohne Objekt | Konvention | — |
Einzel-Operation: is_granted('recht', object) und ein eigener Voter | Still | Niemand prüft. Der PermissionVoter des Kerns enthält sich bei übergebenem Objekt. Jede solche Operation ist ein Kandidat für fremden Datenzugriff über eine geratene Kennung |
| In Diensten nie die allgemeine Prüfung mit einem markenbezogenen Objekt aufrufen, sondern den eigenen Voter direkt fragen | Still | Derselbe Fallstrick |
| Markenbezogene Listen schneiden auf das Recht, nicht auf die Mitgliedschaft | Still | Häufigstes echtes Sicherheitsmuster im Bestand: Wer in einer zweiten Marke Mitglied ist, dort aber kein Recht hat, sah trotzdem deren Daten |
| Jeder Sammel-Filter ist als Abfrage-Parameter deklariert | Still | Er wirkt, steht aber weder in der OpenAPI-Beschreibung noch im Werkzeug-Katalog. Eine KI müsste ihn raten |
Rechte kommen aus PermissionProviderInterface, nie aus einer Migration | Still | Sie werden beim Entfernen nicht aufgeräumt |
getRoleGrants() erweitert Rollen additiv | Konvention | Ein Plugin definiert keine Systemrolle um |
Öffentliche Endpunkte über publicApiPathPatterns() brauchen eine eigene Echtheitsprüfung | Still | Eine offene Tür. Das schaltet die Anmeldepflicht ab, nicht die Prüfung |
| Rückruf-Empfänger sind einfache Controller, keine API-Ressourcen | Konvention | Sie erschienen sonst als Werkzeug im KI-Zugang |
Verträge
Implementieren genügt. Es gibt keine Registrierung, kein Kennzeichen von Hand, keine Konfigurationsdatei.
| Regel | Stufe | Folge bei Verstoß |
|---|
Ein Plugin liefert seinen Modul-Schalter selbst über getFeatureFlags() | Still | Die Zeile fehlt nach der Installation; die Sperre ist fail-closed, alle Routen antworten mit 404 |
| Ein Plugin mit eigener Fachtabelle liefert einen Demo-Beitrag | Still | Eine frisch aufgesetzte Vorführung zeigt an dieser Stelle einen Leerzustand |
| Jede neue Spalte mit Personenbezug bekommt einen Beitrag zu Auskunft und Löschung | Still | Die Zeile bleibt nach einer vollzogenen Löschung stehen. Kein Fehler, keine Warnung, nur Daten, die weg sein müssten |
Eigene Zeilen in Kern-Tabellen räumt UninstallDataParticipantInterface beim Entfernen weg | Still | Waisen im Kern |
| Wer auf ein fremdes Ereignis hört, steigt früh aus, wenn sein Plugin inaktiv ist | Still | Das Plugin wirkt weiter, obwohl es ausgeschaltet ist |
| Ein Veto-Empfänger liest nur — kein Schreiben, kein Speichern | Still | Manche dieser Ereignisse laufen mitten im Speichervorgang |
Die Namensregel bei Ereignissen ist verlässlich: Ein Ereignis auf -ing läuft vorher und ist ablehnbar, eines im Partizip lief bereits und ist eine Tatsache.
Automatisierung
| Regel | Stufe | Folge bei Verstoß |
|---|
Ein Auslöser implementiert TriggerProviderInterface und TriggerCatalogProviderInterface | Still | Er funktioniert, ist aber im Baukasten nicht auswählbar |
getType() gibt eine eigene Konstante der Klasse zurück, kein Fremdklasse::TYPE | Hart | Der Katalog-Wächter der Oberfläche liest den Quelltext und kennt nur Literal oder self::… |
execute() beachtet $context->isDryRun | Still | Der Trockenlauf ändert Daten |
Aktionen mit Pflichtfeldern implementieren AutomationActionConfigValidatorInterface | Still | Eine unvollständige Konfiguration fällt erst beim Lauf auf, und dann beim Empfänger |
Beschriftungen folgen automation.trigger.<kleinCamel> bzw. automation.action.<kleinCamel> | Still | Die Oberfläche zeigt den rohen Schlüssel |
| Aktionen schreiben keinen eigenen Prüfspur-Eintrag | Konvention | Dublette je Feldänderung; die Änderung wird ohnehin protokolliert |
Oberfläche
| Regel | Stufe | Folge bei Verstoß |
|---|
Kern-Bausteine ausschließlich über @/sdk | Hart | Tiefere Verweise werden beim Bau abgewiesen |
| Routen-Pfade ohne führenden Schrägstrich | Still | Die Route hängt an der falschen Stelle |
Komponenten immer als Nachlader (() => import(...)) | Konvention | Alles landet im Einstiegspaket |
localeLoaders sind Nachlader, keine direkten Verweise | Still | Jede Sprache jedes Plugins landet im Einstiegspaket |
In routeModules steht links das Routen-Präfix, rechts der Kurzname des Schalters ohne module. | Still | Trifft der rechte Wert keinen echten Schalter, gilt das Modul immer als eingeschaltet |
| Backend-Übersetzungen liegen zentral, nur Oberflächen-Übersetzungen im Plugin | Hart | Werden sonst nicht geladen |
Tests
| Regel | Stufe | Folge bei Verstoß |
|---|
PHP-Tests liegen zentral unter tests/, nie unter plugins/<Name>/tests/ | Hart | Ein Architektur-Wächter hält das fest. Ein Test, den der Testläufer nicht findet, ist kein Test |
Oberflächen-Tests liegen sehr wohl im Plugin, unter frontend/**/__tests__/ | Konvention | — |
Vor dem Lauf make test-db | Hart | ApiTestCase bricht mit Anleitung ab, statt still 404 zu liefern |
Erbt ein Test von KernelTestCase, seedet er die Schalter selbst über SeedsPluginModuleFlagsTrait | Still | Alle Plugin-Routen 404en, und ein Test auf einen Fehlerfall wird trotzdem grün |
| Zu jeder markenbezogenen Liste gehört ein Test auf den Marken-Schnitt, der auch die Anzahl prüft | Still | Eine Summe zu verbergen und die Anzahl mitzuliefern verrät den fremden Bestand trotzdem |
make gate ist grün, bevor etwas „fertig" heißt | Hart | Das Tor ist die maßgebliche Antwort |
Die Abnahme-Liste
Vor dem Ausliefern, in dieser Reihenfolge:
| Geprüft? | Was |
|---|
| ☐ | Version steht überall gleich: Descriptor, composer.json, plugin.json, CHANGELOG.md |
| ☐ | Jedes API-Präfix steht in getRouteSegments() |
| ☐ | Der Modul-Schalter kommt aus dem eigenen StandardDataProvider |
| ☐ | Migrationen wandern nur additiv und enthalten kein INSERT |
| ☐ | Rechte, Stammdaten und Nachschlagewerte kommen aus den Verträgen |
| ☐ | Jede Einzel-Operation hat einen eigenen Voter |
| ☐ | Jede markenbezogene Liste ist auf das Recht geschnitten |
| ☐ | Jeder Sammel-Filter ist als Parameter deklariert |
| ☐ | Ein Demo-Beitrag existiert, wenn es eine eigene Fachtabelle gibt |
| ☐ | Jede Spalte mit Personenbezug hat einen Beitrag zu Auskunft und Löschung |
| ☐ | Kopplungen an andere Plugins stehen in getDependencies() |
| ☐ | prependExtension() ruft parent:: auf, falls überschrieben |
| ☐ | Der Generator-Rest ist raus |
| ☐ | make gate ist grün |
Weiter
plugin.guidelines · Gilt ab Version 0.6.22