Guidelines
Every binding rule in one place. Each line states the rule and what happens if you break it — because nearly all of these failures are silent. They throw no exception, they simply do not do what you expect.
The complete walkthrough shows every one of these rules in use.
How to read this page
Three levels:
- Hard — the build, a guard or the gate aborts. You cannot get past it.
- Silent — nothing breaks, but the result is wrong. These are the expensive ones.
- Convention — not enforced, but consistent across the codebase. Deviating costs questions.
Naming and identity
| Rule | Level | Consequence of breaking it |
|---|---|---|
Plugin key as <vendor>.<name>, lower case, dotted | Convention | Collision with another plugin |
getPluginKey() in the descriptor, name in plugin.json and extra.octibiz.pluginKey in composer.json are identical | Hard | The installer aborts |
getModuleFlag() is module.<hyphenated-name> and reads the same in plugin.json | Hard | The installer aborts |
getProviderKey() in every provider is the plugin key | Silent | Catalogue orphans are left behind on removal |
| Address segments carry your prefix and claim no core segment | Hard | Abort before any side effect |
Permissions are <plugin>.<object>.<verb>, usually view and manage | Convention | — |
Tables carry a domain prefix, not a vendor one: acme_shipment, not acme_plugin_shipment | Convention | — |
The version is identical in AcmeShippingDescriptor::VERSION, composer.json, plugin.json and the top CHANGELOG.md section | Hard | A checker trips |
The descriptor
| Rule | Level | Consequence of breaking it |
|---|---|---|
PluginDescriptorInterface is the only mandatory implementation | Hard | Without it the plugin does not exist |
Every new API prefix is listed in getRouteSegments() | Silent | No module gate applies to that segment — the route stays reachable although the module is off |
Every coupling to another plugin is listed in getDependencies() | Silent | The dry run before removal wrongly reports "no dependants" |
spiVersion in plugin.json names the contract surface you built against | Hard | If missing, installation proceeds with a warning; a wrong major version aborts |
The bundle
| Rule | Level | Consequence of breaking it |
|---|---|---|
No services.yaml in a plugin | Convention | Not one of the shipped plugins has one |
If you override prependExtension(), call parent:: | Silent | Entity registration, migration path and templates fall away silently. Invisible on an existing developer database because the tables are already there — only a fresh install creates none at all |
A core service is replaced through serviceAliases(), not #[AsAlias] | Silent | The core configuration wins the merge, the replacement has no effect |
| A replaced core service checks at entry whether its plugin is active | Silent | It keeps working although the module is off. Toggling does not rebuild the container |
Data model
| Rule | Level | Consequence of breaking it |
|---|---|---|
Integer primary key plus HasUlid; the API identifier is always the ULID | Silent | Internal counts leak outward; guessable identifiers |
Timestampable and, where it makes sense, SoftDeletable | Convention | — |
BrandScoped for documents and transactions, not for master data | Convention | — |
| Money always in cents as an integer | Convention | Rounding errors |
| Statuses are data in a string column, not an enum in code | Convention | The status can never be configured |
| No foreign keys across plugin boundaries | Silent | Removing one plugin tears the other apart |
| Never write raw into another plugin's table | Silent | This actually happened: after removal with data deletion the project list answered every call with a server error |
Migrations
| Rule | Level | Consequence of breaking it |
|---|---|---|
| Strictly additive, no backward step on a live database | Convention | Data loss on rollback |
Schema only, not a single INSERT | Silent | A second truth that drifts apart at the next reconciliation |
isTransactional(): bool { return false; } | Convention | Matches the codebase |
Own namespace Acme\Shipping\Migrations | Hard | Otherwise not recognised as a migration path |
A plugin that only migrates and ships no contracts has empty tables after installation.
API and permissions
| Rule | Level | Consequence of breaking it |
|---|---|---|
API resources are classes of their own in src/ApiResource/, not the entity | Convention | Internal fields become externally visible |
Collection operation: is_granted('permission') without an object | Convention | — |
Item operation: is_granted('permission', object) and a voter of your own | Silent | Nobody checks. The core PermissionVoter abstains when an object is passed. Every such operation is a candidate for reading someone else's data through a guessed identifier |
| In services never call the generic check with a brand-scoped object; ask your own voter directly | Silent | The same trap |
| Brand-scoped lists are cut by the permission, not by membership | Silent | The most common real security pattern in the codebase: a member of a second brand without a permission there still saw its data |
| Every collection filter is declared as a query parameter | Silent | It works, but appears in neither the OpenAPI description nor the tool catalogue. An AI would have to guess it |
Permissions come from PermissionProviderInterface, never from a migration | Silent | They are not swept up on removal |
getRoleGrants() extends roles additively | Convention | A plugin does not redefine a system role |
Public endpoints via publicApiPathPatterns() need a verification of their own | Silent | An open door. It disables the login requirement, not the check |
| Callback receivers are plain controllers, not API resources | Convention | They would otherwise show up as tools for AI access |
Contracts
Implementing is enough. There is no registration, no manual tag, no configuration file.
| Rule | Level | Consequence of breaking it |
|---|---|---|
A plugin ships its own module switch through getFeatureFlags() | Silent | The row is missing after installation; the gate is fail-closed, all routes answer with 404 |
| A plugin with its own domain table ships a demo contribution | Silent | A freshly set-up demo shows an empty state at that spot |
| Every new column with personal data gets a contribution to disclosure and erasure | Silent | The row survives a completed erasure. No error, no warning, just data that should be gone |
Own rows in core tables are swept by UninstallDataParticipantInterface on removal | Silent | Orphans in the core |
| A listener on a foreign event exits early when its plugin is inactive | Silent | The plugin keeps working although it is switched off |
| A veto listener only reads — no writing, no persisting | Silent | Some of these events run in the middle of a save |
The naming rule for events is reliable: an event ending in -ing runs before and can be rejected; one in the past participle already happened and is a fact.
Automation
| Rule | Level | Consequence of breaking it |
|---|---|---|
A trigger implements TriggerProviderInterface and TriggerCatalogProviderInterface | Silent | It works but cannot be selected in the editor |
getType() returns the class's own constant, not OtherClass::TYPE | Hard | The interface catalogue guard reads the source and knows only a literal or self::… |
execute() honours $context->isDryRun | Silent | The dry run changes data |
Actions with required fields implement AutomationActionConfigValidatorInterface | Silent | An incomplete configuration only surfaces at run time, and then at the recipient |
Labels follow automation.trigger.<lowerCamel> and automation.action.<lowerCamel> | Silent | The interface shows the raw key |
| Actions write no audit entry of their own | Convention | Duplicate per field change; the change is recorded anyway |
Interface
| Rule | Level | Consequence of breaking it |
|---|---|---|
Core building blocks exclusively through @/sdk | Hard | Deeper imports are rejected at build time |
| Route paths without a leading slash | Silent | The route mounts in the wrong place |
Components always lazily loaded (() => import(...)) | Convention | Everything lands in the entry bundle |
localeLoaders are lazy loaders, not direct imports | Silent | Every language of every plugin lands in the entry bundle |
In routeModules the left side is the route prefix, the right side the switch short name without module. | Silent | If the right-hand value matches no real switch, the module counts as always on |
| Backend translations live centrally, only interface translations in the plugin | Hard | Otherwise they are not loaded |
Tests
| Rule | Level | Consequence of breaking it |
|---|---|---|
PHP tests live centrally under tests/, never under plugins/<Name>/tests/ | Hard | An architecture guard holds this. A test the runner does not find is not a test |
Interface tests do live in the plugin, under frontend/**/__tests__/ | Convention | — |
Run make test-db before testing | Hard | ApiTestCase aborts with instructions instead of silently returning 404 |
A test extending KernelTestCase seeds the switches itself via SeedsPluginModuleFlagsTrait | Silent | All plugin routes 404, and a test on an error case passes anyway |
| Every brand-scoped list gets a test on the brand cut that also checks the count | Silent | Hiding a sum while returning the count still discloses the foreign data |
make gate is green before anything is called "done" | Hard | The gate is the authoritative answer |
The acceptance list
Before shipping, in this order:
| Checked? | What |
|---|---|
| ☐ | The version is identical everywhere: descriptor, composer.json, plugin.json, CHANGELOG.md |
| ☐ | Every API prefix is listed in getRouteSegments() |
| ☐ | The module switch comes from your own StandardDataProvider |
| ☐ | Migrations are additive only and contain no INSERT |
| ☐ | Permissions, master data and lookups come from the contracts |
| ☐ | Every item operation has a voter of its own |
| ☐ | Every brand-scoped list is cut by the permission |
| ☐ | Every collection filter is declared as a parameter |
| ☐ | A demo contribution exists if there is an own domain table |
| ☐ | Every column with personal data has a contribution to disclosure and erasure |
| ☐ | Couplings to other plugins are listed in getDependencies() |
| ☐ | prependExtension() calls parent:: if overridden |
| ☐ | The generator leftovers are gone |
| ☐ | make gate is green |
Next
- Complete walkthrough — every one of these rules in use
- Backend — why the rules are what they are
- Testing — the tests that check these rules
- Contract reference — every contract with its signature