Plugin-Backend
Ein Plugin bringt Datenmodell, API, Rechte, Hintergrundverarbeitung und Migrationen mit, ohne eine Zeile am Kern zu ändern. Diese Seite zeigt, wie.
Wo fange ich an? Ziel, dann Mechanismus
Die Erweiterungsfläche ist groß: rund 200 Verträge, 30 Veto-Ereignisse, 84 Andockstellen in der Oberfläche. Sie nach Mechanismus zu durchsuchen führt in die Irre. Such nach deinem Ziel.
| Ich will … | Mechanismus |
|---|---|
| eine neue Entität mit Tabelle und API mitbringen | Entity, Migration, API-Ressource mit eigenem Voter |
| einen Kernvorgang vor der Änderung ablehnen | Veto-Ereignis, siehe unten |
| auf ein Ereignis reagieren, nachdem es passiert ist | Ereignis-Empfänger, Katalog in der Ereignis-Referenz |
| Status, Nachschlagewerte, Einstellungen, Rechte oder Beispieldaten liefern | Der jeweilige Vertrag, siehe Verträge |
| ein Feld, einen Filter oder eine Operation an eine fremde Ressource hängen | Die Erweiterungs-Verträge für Lesen, Schreiben, Filtern, Operationen |
| eine neue Automatisierungs-Aktion oder -Bedingung | Der passende Vertrag, wird über sein Kennzeichen eingesammelt |
| ein eigenes Anmeldeverfahren | Der Anmelde-Vertrag |
| eine Ansicht der Oberfläche erweitern | Eine Andockstelle, siehe Oberfläche |
| jede neue API-Operation als KI-Werkzeug verfügbar machen | Nichts. Das passiert von selbst, sobald es eine API-Ressource ist |
Die vollständige Liste aller Verträge steht in der Vertrags-Referenz.
Datenmodell
Plugin-Entities liegen in src/Entity/ und werden automatisch angemeldet. Es gibt keinen Konfigurationsschritt und keine services.yaml.
Die Konventionen sind dieselben wie im Kern:
- Ganzzahliger Primärschlüssel plus öffentliche ULID. Der öffentliche Bezeichner in der API ist
immer die ULID, nie die interne Zahl.
- Zeitstempel und Urheber über die geteilten Merkmale, weiches Löschen wo fachlich sinnvoll.
- Marken-Bezug für Belege und Vorgänge, nicht für Stammdaten.
- Geld immer in Cent als Ganzzahl. Kein Fließkomma, keine Währungsspalte je Zeile.
- Status sind Daten, kein Aufzählungstyp im Code. Eine stabile Zeichenkette in der Spalte, die
Katalogzeile kommt aus deinem eigenen Standarddaten-Vertrag. Harte Zustandsautomaten, die nie konfigurierbar sein sollen, dürfen Konstanten bleiben.
Tabellennamen tragen ein fachliches Präfix, kein Hersteller-Präfix: crm_deal, nicht octi_crm_deal.
Keine Fremdschlüssel über Plugin-Grenzen. Ein Plugin, das auf ein anderes zeigt, führt die Beziehung über die ID ohne Datenbank-Zwang. Sonst zerlegt das Entfernen des einen das andere.
Migrationen
Sie liegen in src/Migrations/ mit eigenem Namensraum. Alle Plugins teilen sich die Versionstabelle, sortiert wird nach Zeitstempel.
Zwei Regeln:
- Rein additiv. Kein Rückwärtsschritt auf einer laufenden Datenbank.
- Nur Schema, keine Inhalte. Standarddaten, Rechte und Nachschlagewerte gehören in die
jeweiligen Verträge. Eine Migration, die Zeilen einfügt, hinterlässt eine zweite Wahrheit, die beim nächsten Abgleich auseinanderläuft.
Ein Plugin, das nur migriert und keine Verträge liefert, hat nach der Installation leere Tabellen.
API und die Pflicht zur Rechteprüfung
API-Ressourcen liegen in src/ApiResource/ und werden automatisch gefunden. Sie sind eigene Klassen, nicht die Entity selbst.
#[ApiResource(
shortName: 'AcmeShipment',
routePrefix: '/v1',
operations: [
new GetCollection(
uriTemplate: '/acme-shipping/shipments',
security: "is_granted('acme.shipping.view')",
provider: ShipmentCollectionProvider::class,
),
new Get(
uriTemplate: '/acme-shipping/shipments/{ulid}',
uriVariables: ['ulid'],
security: "is_granted('acme.shipping.view', object)",
provider: ShipmentItemProvider::class,
),
new Post(
uriTemplate: '/acme-shipping/shipments',
security: "is_granted('acme.shipping.manage')",
processor: CreateShipmentProcessor::class,
),
],
normalizationContext: ['groups' => ['acme_shipping:read']],
)]
Der Unterschied zwischen den Zeilen ist der wichtigste Absatz dieser Seite:
- Sammel-Operationen prüfen ohne Objekt. Das beantwortet die Rechteprüfung des Kerns aus dem
gemeinsamen Katalog. Markenbezogene Listen filtern zusätzlich auf die Marken, in denen das Recht tatsächlich gehalten wird.
- Einzel-Operationen prüfen mit Objekt und brauchen einen eigenen Voter.
Warum ein eigener Voter Pflicht ist
Die Rechteprüfung des Kerns enthält sich bewusst, sobald ein Objekt übergeben wird. Ohne diese Zurückhaltung würde das Katalog-Recht jede Objektregel überstimmen, weil schon eine einzige Zustimmung genügt.
Die Folge ist unangenehm: Wer eine Einzel-Operation mit object absichert, aber keinen eigenen Voter mitbringt, hat niemanden, der die Regel prüft. Jede solche Operation ist ein Kandidat für fremden Datenzugriff über eine geratene Kennung.
Derselbe Fallstrick in Diensten: Dort nie die allgemeine Prüfung mit einem markenbezogenen Objekt aufrufen, sondern den eigenen Voter direkt fragen.
Ein Voter prüft beides: das Recht und die Objektregel, also Marken-Zugehörigkeit über die Mitgliedschaften, Eigentümerschaft und Status.
Filter sichtbar machen
Ein Sammel-Filter wirkt auch ohne Deklaration. Aber er steht dann weder in der OpenAPI-Beschreibung noch im Werkzeug-Katalog für den KI-Zugang. Eine KI müsste ihn raten. Deklariere jeden Filter als Abfrage-Parameter.
Rechte
Rechte kommen aus einem eigenen Vertrag, nie aus einer Migration:
final class AcmeShippingPermissionProvider implements PermissionProviderInterface
{
public function getPermissions(): array
{
return [
'acme.shipping.view' => ['Versand', 'Versand: Sendungen sehen'],
'acme.shipping.manage' => ['Versand', 'Versand: Sendungen verwalten'],
];
}
public function getRoleGrants(): array
{
return [
'PM' => ['acme.shipping.view', 'acme.shipping.manage'],
'Support' => ['acme.shipping.view'],
];
}
public function getProviderKey(): string
{
return 'acme.shipping';
}
}
Der Anbieter-Schlüssel muss der Plugin-Schlüssel sein. Er ist das Bindeglied zur Eigentums-Erfassung; beim Entfernen räumt das System genau die Zeilen weg, die diesen Schlüssel tragen. Ein abweichender Schlüssel hinterlässt Waisen.
Plugins ergänzen Rechte und Rollen-Zuweisungen. Sie definieren keine Systemrollen um.
Hintergrundverarbeitung
Lange Arbeit gehört nicht in den Aufruf. Ein Plugin meldet eigenes Nachrichten-Routing selbst an:
public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
parent::prependExtension($container, $builder);
$container->extension('framework', [
'messenger' => ['routing' => [
SendShipmentNotification::class => 'outbox',
]],
]);
}
Der Aufruf von parent:: ist Pflicht. Fehlt er, fällt lautlos aus, was die Basisklasse erledigt: Entity-Anmeldung, Migrationspfad, Vorlagen. Dieser Fehler ist real passiert und war besonders unangenehm, weil er auf bestehenden Entwickler-Datenbanken unsichtbar blieb: Die Tabellen waren ja schon da. Erst eine Frischinstallation legte keine einzige Tabelle des Plugins mehr an.
Wiederkehrende Arbeit meldet ein Plugin über den Zeitplan-Vertrag an.
Öffentliche Endpunkte
Für Rückrufe fremder Systeme, die keine Anmeldung mitbringen:
public function publicApiPathPatterns(): array
{
return ['^/api/v1/acme-shipping/public/'];
}
Das schaltet die Anmeldepflicht ab, nicht die Prüfung. Die Echtheit muss anders belegt werden, etwa über eine Signatur des Absenders. Ein öffentlicher Endpunkt ohne eigene Prüfung ist eine offene Tür.
Für solche Rückruf-Empfänger ist ein einfacher Controller richtig, keine API-Ressource. Er soll bewusst nicht im Werkzeug-Katalog für die KI auftauchen.
Verträge implementieren
Der größte Teil der Erweiterung läuft additiv über Verträge. Implementieren genügt — es gibt keine Registrierung, kein Kennzeichen von Hand, keine Konfigurationsdatei.
Die häufigsten:
| Vertrag | Wofür |
|---|---|
StandardDataProviderInterface | Status, Nachschlagewerte, Einstellungen, Merkmalsschalter |
PermissionProviderInterface | Berechtigungen und Rollen-Zuweisungen |
DemoContributorInterface | Beispieldaten für eine Vorführ-Instanz |
UninstallDataParticipantInterface | Eigene Zeilen in Kern-Tabellen beim Entfernen |
PluginDependencyProviderInterface | Auf welche Plugins dieses angewiesen ist |
PolymorphicLinkProviderInterface | Wo dieses Plugin auf fremde Objekte zeigt |
DataSubjectContributorInterface | Auskunft (Art. 15) und Löschung (Art. 17) für eigene Personendaten |
Ein Plugin mit eigener Fachtabelle braucht einen Demo-Beitrag. Sonst zeigt eine frisch aufgesetzte Vorführung an dieser Stelle einen Leerzustand, und das eingecheckte Handbuchbild zeigt eine Fehlerseite.
Eine neue Spalte mit Personenbezug braucht DataSubjectContributorInterface. Ohne ihn bleibt die Zeile nach einer vollzogenen Löschung stehen: kein Fehler, keine Warnung, nur Daten, die weg sein müssten.
Die vollständige Liste steht in der Vertrags-Referenz.
Einen Kern-Dienst ersetzen
Wenn ein Plugin eine Vertragsstelle des Kerns übernehmen soll:
protected function serviceAliases(): array
{
return [
ShippingRateGatewayInterface::class => AcmeRateService::class,
];
}
Das setzt den Verweis nach dem Zusammenführen der Konfiguration. Ein #[AsAlias] am Dienst täte es nicht: Die Konfiguration des Kerns gewinnt beim Zusammenführen immer, der Ersatz bliebe wirkungslos.
Eine solche Implementierung muss sich selbst prüfen. Das Umschalten eines Moduls baut den Container nicht neu. Der Dienst bleibt also verdrahtet, auch wenn sein Plugin ausgeschaltet ist. Frag zu Beginn, ob dein Plugin aktiv ist, und steig sonst aus. Real passiert: Ein Zeit-Befehl aus einem Chat-Werkzeug buchte weiter Zeiten, obwohl die Zeiterfassung abgeschaltet war.
Abhängigkeiten zwischen Plugins
Jede Kopplung an ein anderes Plugin gehört deklariert. Der Descriptor nennt sie:
public function getDependencies(): array
{
return ['octi.projects'];
}
Das steuert die Ladereihenfolge und schützt davor, dass jemand das benötigte Plugin entfernt, während deines es braucht.
Nie roh in die Tabelle eines fremden Plugins schreiben. Real passiert und teuer: Ein Plugin schrieb per SQL in die Tabelle eines anderen, ohne die Kopplung zu deklarieren. Der Trockenlauf vor dem Entfernen meldete daraufhin „keine Abhängigen" — ein falsches Sicherheitssignal. Nach dem Entfernen mit Datenlöschung beantwortete die Projektliste jeden Aufruf mit einem Serverfehler.
In Kernvorgänge eingreifen
Für „das darf jetzt nicht passieren" gibt es Veto-Ereignisse. Sie laufen vor der Änderung und synchron:
#[AsEventListener]
public function onOrderConfirming(OrderConfirming $event): void
{
if (null !== $this->enablement && !$this->enablement->isObjectEnabled($this)) {
return;
}
if ($this->overLimit($event->customerId)) {
$event->veto('Kreditlimit überschritten', 'acme.creditcontrol');
}
}
Zwei Dinge daran sind wichtig:
Der Frühausstieg ist Pflicht, wenn du auf ein fremdes Ereignis hörst. Das Ereignis feuert unabhängig von deinem Plugin. Ohne die Prüfung wirkt dein Plugin weiter, obwohl es ausgeschaltet ist.
Ein Veto-Empfänger liest nur. Kein Schreiben, kein Speichern, keine Seiteneffekte. Manche dieser Ereignisse laufen mitten im Speichervorgang.
Die Namensregel ist verlässlich: Ein Ereignis auf -ing läuft vorher und ist ablehnbar, eines im Partizip lief bereits und ist eine Tatsache.
Ereignisse nach der Änderung sind der andere Weg. Sie sind nicht ablehnbar, dafür lösen sie Automatisierungen und Rückrufe aus. Beide Kataloge stehen in der Ereignis-Referenz.
Eigene Vorlagen
Ein Ordner templates/ im Plugin wird automatisch als eigener Namensraum angemeldet und ist unter @AcmeShipping/... erreichbar.
Was schiefgeht, und woran es liegt
| Symptom | Ursache |
|---|---|
| Nach Frischinstallation existiert keine Tabelle des Plugins | parent::prependExtension() vergessen |
| Alle Routen antworten mit 404, obwohl installiert | Modul-Schalter nicht eingeschaltet, oder das Plugin seedet ihn nicht selbst |
| Eine Route bleibt trotz ausgeschaltetem Modul erreichbar | Neues Adress-Segment nicht im Descriptor deklariert |
| Fremde Daten sind über eine geratene Kennung lesbar | Einzel-Operation ohne eigenen Voter |
| Ein Plugin wirkt weiter, obwohl ausgeschaltet | Kein Frühausstieg im Empfänger oder im ersetzten Dienst |
| Tabellen sind da, aber leer | Standarddaten in die Migration geschrieben statt in den Vertrag |
| Trockenlauf meldet keine Abhängigen, das Entfernen bricht trotzdem etwas | Kopplung nicht deklariert |
Weiter
- Oberfläche — Ansichten, Andockstellen, Navigation
- Testen — und warum eine frische Testdatenbank alles ausschaltet
- Lebenszyklus — installieren, einschalten, entfernen
- Vertrags-Referenz — alle Verträge mit Signatur