Octibiz
Demo

Durchsucht Website und Dokumentation gemeinsam. Enter zeigt alle Treffer, Esc schließt.

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 mitbringenEntity, Migration, API-Ressource mit eigenem Voter
einen Kernvorgang vor der Änderung ablehnenVeto-Ereignis, siehe unten
auf ein Ereignis reagieren, nachdem es passiert istEreignis-Empfänger, Katalog in der Ereignis-Referenz
Status, Nachschlagewerte, Einstellungen, Rechte oder Beispieldaten liefernDer jeweilige Vertrag, siehe Verträge
ein Feld, einen Filter oder eine Operation an eine fremde Ressource hängenDie Erweiterungs-Verträge für Lesen, Schreiben, Filtern, Operationen
eine neue Automatisierungs-Aktion oder -BedingungDer passende Vertrag, wird über sein Kennzeichen eingesammelt
ein eigenes AnmeldeverfahrenDer Anmelde-Vertrag
eine Ansicht der Oberfläche erweiternEine Andockstelle, siehe Oberfläche
jede neue API-Operation als KI-Werkzeug verfügbar machenNichts. 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:

VertragWofür
StandardDataProviderInterfaceStatus, Nachschlagewerte, Einstellungen, Merkmalsschalter
PermissionProviderInterfaceBerechtigungen und Rollen-Zuweisungen
DemoContributorInterfaceBeispieldaten für eine Vorführ-Instanz
UninstallDataParticipantInterfaceEigene Zeilen in Kern-Tabellen beim Entfernen
PluginDependencyProviderInterfaceAuf welche Plugins dieses angewiesen ist
PolymorphicLinkProviderInterfaceWo dieses Plugin auf fremde Objekte zeigt
DataSubjectContributorInterfaceAuskunft (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

SymptomUrsache
Nach Frischinstallation existiert keine Tabelle des Pluginsparent::prependExtension() vergessen
Alle Routen antworten mit 404, obwohl installiertModul-Schalter nicht eingeschaltet, oder das Plugin seedet ihn nicht selbst
Eine Route bleibt trotz ausgeschaltetem Modul erreichbarNeues Adress-Segment nicht im Descriptor deklariert
Fremde Daten sind über eine geratene Kennung lesbarEinzel-Operation ohne eigenen Voter
Ein Plugin wirkt weiter, obwohl ausgeschaltetKein Frühausstieg im Empfänger oder im ersetzten Dienst
Tabellen sind da, aber leerStandarddaten in die Migration geschrieben statt in den Vertrag
Trockenlauf meldet keine Abhängigen, das Entfernen bricht trotzdem etwasKopplung nicht deklariert

Weiter

plugin.backend · Gilt ab Version 0.5.0