Octibiz
Demo

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

Plugin backend

A plugin brings along a data model, API, permissions, background processing and migrations without changing a single line of the core. This page shows how.

Where do I start? Goal first, then mechanism

The extension surface is large: around 200 contracts, 30 veto events, 84 extension points in the interface. Searching it by mechanism leads you astray. Search by your goal.

I want to …Mechanism
bring along a new entity with table and APIentity, migration, API resource with its own voter
refuse a core operation before the changeveto event, see below
react to an event after it has happenedevent listener, catalogue in the event reference
ship statuses, lookup values, settings, permissions or demo datathe respective contract, see contracts
attach a field, a filter or an operation to a foreign resourcethe extension contracts for reading, writing, filtering, operations
a new automation action or conditionthe matching contract, it is collected via its tag
your own login methodthe login contract
extend a view of the interfacean extension point, see interface
make every new API operation available as an AI toolnothing. That happens by itself as soon as it is an API resource

The complete list of all contracts is in the contract reference.

Data model

Plugin entities live in src/Entity/ and are registered automatically. There is no configuration step and no services.yaml.

The conventions are the same as in the core:

  • Integer primary key plus public ULID. The public identifier in the API is always the ULID,

never the internal number.

  • Timestamps and authorship via the shared traits, soft delete wherever it makes sense for

the business case.

  • Brand reference for documents and transactions, not for master data.
  • Money always in cents as an integer. No floating point, no currency column per row.
  • Statuses are data, not an enum type in code. A stable string in the column, the catalogue row

comes from your own standard data contract. Hard state machines that are never meant to be configurable may stay constants.

Table names carry a domain prefix, not a vendor prefix: crm_deal, not octi_crm_deal.

No foreign keys across plugin boundaries. A plugin that points at another one carries the relation through the ID without a database constraint. Otherwise removing the one takes the other apart.

Migrations

They live in src/Migrations/ with a namespace of their own. All plugins share the version table; sorting is by timestamp.

Two rules:

  • Purely additive. No backward step on a running database.
  • Schema only, no content. Standard data, permissions and lookup values belong in the respective

contracts. A migration that inserts rows leaves behind a second truth that drifts apart at the next reconciliation.

A plugin that only migrates and ships no contracts has empty tables after installation.

API and the mandatory permission check

API resources live in src/ApiResource/ and are found automatically. They are classes of their own, not the entity itself.

#[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']],
)]

The difference between the lines is the most important paragraph on this page:

  • Collection operations check without an object. The core's permission check answers that

from the shared catalogue. Brand-bound lists additionally filter to the brands in which the permission is actually held.

  • Item operations check with an object and need a voter of their own.

Why a voter of your own is mandatory

The core's permission check deliberately abstains as soon as an object is passed. Without that restraint the catalogue permission would override every object rule, because a single approval is already enough.

The consequence is unpleasant: whoever secures an item operation with object but brings no voter of their own has nobody who checks the rule. Every such operation is a candidate for foreign data access through a guessed identifier.

The same pitfall in services: never call the general check there with a brand-bound object, ask your own voter directly.

A voter checks both: the permission and the object rule, that is brand membership through the memberships, ownership and status.

Making filters visible

A collection filter works even without a declaration. But then it appears neither in the OpenAPI description nor in the tool catalogue for AI access. An AI would have to guess it. Declare every filter as a query parameter.

Permissions

Permissions come from a contract of their own, never from a 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';
    }
}

The provider key must be the plugin key. It is the link to the ownership record; on removal the system sweeps away exactly the rows that carry this key. A deviating key leaves orphans behind.

Plugins add permissions and role grants. They do not redefine system roles.

Background processing

Long work does not belong in the request. A plugin registers its own message routing itself:

public function prependExtension(ContainerConfigurator $container, ContainerBuilder $builder): void
{
    parent::prependExtension($container, $builder);

    $container->extension('framework', [
        'messenger' => ['routing' => [
            SendShipmentNotification::class => 'outbox',
        ]],
    ]);
}

The parent:: call is mandatory. If it is missing, everything the base class does fails silently: entity registration, migration path, templates. This mistake actually happened and was particularly unpleasant because it stayed invisible on existing developer databases: the tables were already there. Only a fresh installation created not a single table of the plugin any more.

Recurring work is registered by a plugin through the schedule contract.

Public endpoints

For callbacks from foreign systems that bring no authentication:

public function publicApiPathPatterns(): array
{
    return ['^/api/v1/acme-shipping/public/'];
}

That switches off the authentication requirement, not the check. Authenticity has to be proven some other way, for example through a signature from the sender. A public endpoint without a check of its own is an open door.

For such callback receivers a plain controller is the right thing, not an API resource. It is deliberately not meant to show up in the tool catalogue for the AI.

Implementing contracts

The largest part of extension runs additively through contracts. Implementing is enough — there is no registration, no tag by hand, no configuration file.

The most common ones:

ContractWhat for
StandardDataProviderInterfaceStatuses, lookup values, settings, feature switches
PermissionProviderInterfacePermissions and role grants
DemoContributorInterfaceSample data for a demo instance
UninstallDataParticipantInterfaceYour own rows in core tables on removal
PluginDependencyProviderInterfaceWhich plugins this one relies on
PolymorphicLinkProviderInterfaceWhere this plugin points at foreign objects
DataSubjectContributorInterfaceDisclosure (Art. 15) and erasure (Art. 17) for your own personal data

A plugin with a business table of its own needs a demo contribution. Otherwise a freshly set up demo shows an empty state at that point, and the checked-in manual screenshot shows an error page.

A new column with personal data needs DataSubjectContributorInterface. Without it the row stays in place after a completed deletion: no error, no warning, just data that ought to be gone.

The complete list is in the contract reference.

Replacing a core service

When a plugin is to take over a contract point of the core:

protected function serviceAliases(): array
{
    return [
        ShippingRateGatewayInterface::class => AcmeRateService::class,
    ];
}

That sets the alias after the configuration has been merged. An #[AsAlias] on the service would not do it: the core's configuration always wins during the merge, the replacement would have no effect.

Such an implementation must check itself. Switching a module does not rebuild the container. So the service stays wired up even when its plugin is switched off. Ask at the start whether your plugin is enabled, and bail out otherwise. Actually happened: a time command from a chat tool kept booking times although time tracking was switched off.

Dependencies between plugins

Every coupling to another plugin belongs declared. The descriptor names it:

public function getDependencies(): array
{
    return ['octi.projects'];
}

That controls the load order and protects against someone removing the required plugin while yours needs it.

Never write raw into the table of a foreign plugin. Actually happened and expensive: a plugin wrote into another one's table through SQL without declaring the coupling. The dry run before removal then reported "no dependants" — a false safety signal. After the removal with data deletion the project list answered every request with a server error.

Intervening in core operations

For "this must not happen now" there are veto events. They run before the change and synchronously:

#[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');
    }
}

Two things about it are important:

The early exit is mandatory when you listen to a foreign event. The event fires independently of your plugin. Without the check your plugin keeps working although it is switched off.

A veto listener only reads. No writing, no saving, no side effects. Some of these events run in the middle of the save operation.

The naming rule is reliable: an event ending in -ing runs beforehand and can be refused, one in the participle already ran and is a fact.

Events after the change are the other route. They cannot be refused, but in return they trigger automations and callbacks. Both catalogues are in the event reference.

Your own templates

A templates/ folder in the plugin is automatically registered as a namespace of its own and is reachable under @AcmeShipping/....

What goes wrong, and why

SymptomCause
After a fresh installation no table of the plugin existsparent::prependExtension() forgotten
All routes answer with 404 although installedModule switch not enabled, or the plugin does not seed it itself
A route stays reachable despite the module being switched offNew address segment not declared in the descriptor
Foreign data is readable through a guessed identifierItem operation without a voter of its own
A plugin keeps working although switched offNo early exit in the listener or in the replaced service
Tables are there but emptyStandard data written into the migration instead of into the contract
Dry run reports no dependants, removal still breaks somethingCoupling not declared

Next

plugin.backend · Available from version 0.5.0