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 API | entity, migration, API resource with its own voter |
| refuse a core operation before the change | veto event, see below |
| react to an event after it has happened | event listener, catalogue in the event reference |
| ship statuses, lookup values, settings, permissions or demo data | the respective contract, see contracts |
| attach a field, a filter or an operation to a foreign resource | the extension contracts for reading, writing, filtering, operations |
| a new automation action or condition | the matching contract, it is collected via its tag |
| your own login method | the login contract |
| extend a view of the interface | an extension point, see interface |
| make every new API operation available as an AI tool | nothing. 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:
| Contract | What for |
|---|---|
StandardDataProviderInterface | Statuses, lookup values, settings, feature switches |
PermissionProviderInterface | Permissions and role grants |
DemoContributorInterface | Sample data for a demo instance |
UninstallDataParticipantInterface | Your own rows in core tables on removal |
PluginDependencyProviderInterface | Which plugins this one relies on |
PolymorphicLinkProviderInterface | Where this plugin points at foreign objects |
DataSubjectContributorInterface | Disclosure (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
| Symptom | Cause |
|---|---|
| After a fresh installation no table of the plugin exists | parent::prependExtension() forgotten |
| All routes answer with 404 although installed | Module switch not enabled, or the plugin does not seed it itself |
| A route stays reachable despite the module being switched off | New address segment not declared in the descriptor |
| Foreign data is readable through a guessed identifier | Item operation without a voter of its own |
| A plugin keeps working although switched off | No early exit in the listener or in the replaced service |
| Tables are there but empty | Standard data written into the migration instead of into the contract |
| Dry run reports no dependants, removal still breaks something | Coupling not declared |
Next
- Interface — views, extension points, navigation
- Testing — and why a fresh test database disables everything
- Lifecycle — install, enable, remove
- Contract reference — all contracts with signatures