Plugin development: overview
A plugin is a self-contained package with a namespace of its own. It brings along a data model, API, permissions, background processing, migrations and interface, without changing a single line of the core.
That is not politeness towards the core but the condition under which a system update does not take your plugin apart.
Start here
If you only read one page, read the complete walkthrough. It builds a working plugin from nothing and shows every file in full — bundle, descriptor, entity, migration, API resource, voter, permissions, interface and test. About an hour.
The rules that apply along the way are collected in the guidelines, each with the consequence of breaking it.
This page explains the why behind it.
Three building blocks carry everything
The bundle loads the code — a class extending AbstractPlugin. An empty body is enough: the base class finds services, registers src/Entity/ as the data model, registers src/Migrations/ and templates/.
The descriptor implements PluginDescriptorInterface: key, name, version, trust tier, its own module switch and the API segments it claims. It is the only mandatory implementation.
The contracts are the way a plugin contributes additively: master data, permissions, demo data, automation building blocks, search, number ranges. There are around 200 of them, and a plugin implements exactly the ones it needs. Implementing is enough; there is no registration.
A plugin never changes a catalogue of the core. It supplies rows of its own and registers as their owner. That is exactly how the system knows on removal what has to be cleaned up.
The module switch locks when in doubt
Every route of a plugin is reachable only if its switch exists and is enabled. If the row is missing, or the database cannot be asked, the route answers with 404.
That is the inverse logic to the core, whose modules count as enabled without an entry. The reason: a plugin is foreign code. In doubt, closed is the safe answer.
The 404 instead of a 403 is deliberate as well. A 403 would give away that the route exists.
What a plugin does not have
Whoever comes from other extension systems looks for three things in vain:
- No lifecycle methods on the bundle. No
install(), noupdate(). Participation runs through
contracts, see Lifecycle.
- No configuration file for services. Not a single shipped plugin has one.
- No backend translations of its own. Those live centrally. Only the interface brings its own
along.
The way through this documentation
| If you … | Read |
|---|---|
| want to build a plugin from nothing | Your first plugin |
| want to know what you must comply with | Guidelines |
| only need the commands | Quick start |
| want to know what each file does | Anatomy |
| need data model, API and permissions | Backend |
| contribute views or extend foreign ones | Interface |
| translate labels | Translations |
| want to understand what happens on installation | Lifecycle |
| want to ship | Distribution |
| want to check that it holds | Testing |
Plus the generated references, which come straight from the code and therefore cannot go stale:
- Contract reference — every contract with its signature
- Extension points — all places in the interface
- Manifest — every key
- Events — what you can intercept and trigger
- API and MCP — every operation
Two sentences that save time
Enter every new API prefix in the descriptor. No lock applies to an undeclared segment; the route stays reachable even when the module is off.
Call parent:: when you override a method of the base class. Otherwise whatever it handles drops out silently, and the error only shows up on a fresh installation.