Anatomy of a plugin
What each file does, and which ones you really need.
AcmeShipping/
├── composer.json # package metadata, type octibiz-plugin
├── plugin.json # manifest the installer reads before boot
├── CHANGELOG.md # top section matches the version in the descriptor
├── src/
│ ├── AcmeShippingPlugin.php # the bundle
│ ├── AcmeShippingDescriptor.php # the self-description
│ ├── Entity/ # data model, registered automatically
│ ├── ApiResource/ # API resources, found automatically
│ ├── State/ # providers and processors for them
│ ├── Security/ # voter and permission contract
│ ├── Catalog/ # standard data, demo, links
│ ├── Migrations/ # own, additive migrations
│ ├── Service/ Command/ EventListener/ Controller/ # as needed
│ └── Resources/icon.svg # mandatory
├── templates/ # optional, own template namespace
└── frontend/ # optional
├── plugin.manifest.ts
├── locales/de.ts, en.ts
└── views/ components/
What is mandatory
Measured across every shipped plugin, seven things are present in each one: composer.json, plugin.json, CHANGELOG.md, the bundle, the descriptor, a Catalog/ folder and the icon.
Everything else is optional. The smallest shipped plugin consists of ten files.
No plugin has a services.yaml. Services are discovered, not registered.
The bundle class
final class AcmeShippingPlugin extends AbstractPlugin
{
}
An empty body is valid and the normal case. The base class takes care of:
- Finding and wiring all services under
src/. - Registering
src/Entity/as the data model. - Registering
src/Migrations/as a migration path. - Registering
templates/as a template namespace of its own. src/ApiResource/is found by the API layer itself.
There are four places where you can step in, and all of them are optional:
| Method | What for |
|---|---|
serviceBinds() | Bind fixed values into constructors |
serviceAliases() | Take over a contract slot of the core |
publicApiPathPatterns() | Endpoints without a login requirement |
prependExtension() | Your own message routing, your own limiters |
If you override prependExtension(), call parent::. Otherwise whatever the base handles drops out silently. That is the most expensive mistake in this system, because it stays invisible on an existing developer database: the tables are there already. Only a fresh installation creates not a single one any more.
The descriptor
The only mandatory implementation. It answers seven questions:
final class AcmeShippingDescriptor implements PluginDescriptorInterface
{
public const VERSION = '0.1.0';
public function getPluginKey(): string { return 'acme.shipping'; }
public function getDisplayName(): string { return 'Acme Versand'; }
public function getVersion(): string { return self::VERSION; }
public function getTrustTier(): string { return self::TRUST_COMMUNITY; }
public function getBundleClass(): string { return AcmeShippingPlugin::class; }
public function getModuleFlag(): string { return 'module.acme-shipping'; }
/** Every new API prefix belongs in here. */
public function getRouteSegments(): array { return ['acme-shipping']; }
}
Three consumers read it: the route lock, the inventory in the interface and the lifecycle commands.
const VERSION is the authoritative source. The same number stands in both package files and in the changelog. A check holds them together; the version command moves them all along.
getRouteSegments() is the line that gets forgotten most often. If you introduce a new API prefix and do not add it here, no lock applies to that segment. The route stays reachable even when the module is off.
Usually the descriptor additionally implements the dependency contract:
public function getDependencies(): array
{
return ['octi.projects'];
}
Why there are two manifest files
That is the most common question of understanding, and the answer is one of timing.
plugin.json is read by the installer before the application boots. It has to register the namespace and be able to load the bundle, and for that it needs the details before there is a PHP descriptor it could ask.
{
"name": "acme.shipping",
"version": "0.1.0",
"namespace": "Acme\\Shipping\\",
"bundle": "Acme\\Shipping\\AcmeShippingPlugin",
"moduleFlag": "module.acme-shipping",
"routeSegments": ["acme-shipping"],
"hasFrontend": true,
"spiVersion": "3.15"
}
The namespace needs the trailing backslash. name and moduleFlag have to match the descriptor.
composer.json carries the display metadata:
{
"name": "acme/octibiz-shipping",
"version": "0.1.0",
"type": "octibiz-plugin",
"require": { "php": ">=8.4" },
"extra": {
"octibiz": {
"pluginKey": "acme.shipping",
"technicalName": "AcmeShipping",
"label": "Acme Versand",
"icon": "src/Resources/icon.svg",
"trustTier": "community"
}
}
}
The icon is shipped as an embedded graphic so that the interface can show it without a second request.
The contract version
spiVersion names the contract surface you built against. When installing from a package the system checks it before any side effect:
- A different major version — hard abort. The promise of backward compatibility only holds
within one major line.
- A newer minor version than the instance — abort as well. You built against something that does
not exist here yet.
- The entry is missing — installation with a warning in the log.
The route through the package manager does not check it. There you are responsible yourself.
Not in the plugin
Two things people expect there that live somewhere else:
- Backend translations. They live centrally, not in the plugin. Only the interface translations
belong in the plugin. See Translations.
- PHP tests. They live centrally. A test in the plugin folder is never run. See
Next
- Backend — data model, API, permissions
- Interface — views and extension points
- Distribution — building a package, keeping versions in step