Lifecycle and commands
A plugin passes through four states: registered, installed, enabled, removed. Confusing them means looking for faults in the wrong place.
Three stores, three different questions
The most common misconception is to treat the state as one thing. There are three, and they answer different questions:
| Question | Where the answer lives |
|---|---|
| Which bundles exist at all? | config/plugins.php (committed), plus the generated Composer list and the per-instance runtime list |
| Is the module enabled? | A module.<plugin> row in the database |
| Was it installed, or deliberately removed? | The provisioning state in the database |
The third store serves a purpose that is not obvious: it remembers a deliberate removal. Without it, a system update would reinstate an uninstalled plugin, because the bundle is still registered.
The switch is fail-closed
For plugins the rule is inverted compared to the core:
- Core: if the switch row is missing, the module counts as enabled.
- Plugin: the row must exist and be on. If it is missing, or the database cannot be
queried, every route of the plugin answers with 404.
That is why the install command creates the switch disabled. A freshly installed plugin becomes reachable when you enable it, not before.
The 404 is deliberate, not an oversight: a 403 would reveal that the route exists.
There are no lifecycle methods on the bundle
Coming from other systems, you will look for install(), activate() or update() on the bundle class. They do not exist here, and that is a decision rather than a gap.
Taking part in the lifecycle works through contracts you implement:
| What you want | What you implement |
|---|---|
| Object before a transition | Listen to PluginLifecycleChanging and cast a veto |
| React after a transition | Listen to PluginLifecycleChanged |
| Ship master data | StandardDataProviderInterface |
| Ship permissions | PermissionProviderInterface |
| Ship demo data | DemoContributorInterface |
| Clean up your own rows in core tables on removal | UninstallDataParticipantInterface |
| Load order and protection against premature removal | PluginDependencyProviderInterface |
All but the two events live under App\Platform\Plugin\Contract\; the permission contract lives under App\Platform\Auth\Contract\.
The benefit: a plugin that needs none of this writes none of it. An empty body is valid.
An update is not a state of its own
There is no update() step. A new version consists of three ordinary things: raise the version number, ship migrations, release. Migrations run on install, the rest is a code swap.
What happens to the data on removal
Keeping is the default. Removal disables the plugin, sweeps its rows out of the shared catalogues (permissions, master data, lookups) and drops the switch. The plugin's own tables stay in place, so that a reinstall picks them up again.
Only --delete-data discards them. Then an order applies that is easy to miss: the migration history is forgotten first, the tables are dropped after. The other way round the system would consider the migration applied and create nothing on reinstall.
While you keep, the system also suspends every rule that would sweep foreign data along. That is the difference between a clean removal and irreversible data loss in the core.
Before the real thing, run the dry run:
bin/console octibiz:plugin:uninstall acme.shipping --dry-run
The commands
Every command takes the plugin key, not the folder name.
Everyday
bin/console octibiz:plugin:list # inventory with version, trust tier, state
bin/console octibiz:plugin:install <key> [--activate] # set up, create switch (disabled)
bin/console octibiz:plugin:activate <key> # enable
bin/console octibiz:plugin:deactivate <key> # disable, data stays, routes 404
bin/console octibiz:plugin:uninstall <key> # remove; --dry-run, --delete-data, --keep-data, --force
Enabling and disabling is a pure switch flip. It takes effect immediately and needs no container or interface rebuild.
Runtime packages
bin/console octibiz:plugin:install-package path/to/package.zip # unpack, set up, migrate, rebuild interface
bin/console octibiz:plugin:uninstall-runtime <key> [--purge] # remove files, rebuild
Tools
bin/console octibiz:plugin:rebuild-container # after changing the plugin set at runtime
bin/console octibiz:plugin:rebuild-spa # rebuild the interface, atomic swap
bin/console octibiz:plugin:purge-tables --namespace <ns> # drop tables, strictly bounded to the namespace
bin/console octibiz:plugin:reconcile-ownership # backfill missing ownership records
bin/console octibiz:plugin:run-jobs # clean up orphaned package uploads
The interface rebuild has a safety net: it builds into a side directory, checks the result and swaps only then. If the build fails, the previous version stays live.
What the install command does not do
It runs no migrations. When setting up from a checkout you run them yourself:
bin/console doctrine:migrations:migrate --no-interaction
The package route does this for you, there it is part of the sequence.
When something goes wrong
The install command holds a per-plugin lock and rolls back on failure. Two checks run before any side effect and abort hard:
- The plugin was built against a different major version of the contract.
- It claims an address segment that belongs to the core.
The second case is the nastier one if it got through: the core route would afterwards hang on the plugin's fail-closed switch. Because the switch is created disabled, the installation alone would make a core route unreachable.
On activation the system additionally checks whether the plugin's data models compile at all. All plugins share one database access; a faulty model would break the metadata for the whole application. The activation therefore aborts rather than taking the platform down with it.
Next
- Distribution — building a package, versions, delivery routes
- Testing — why a fresh test database disables every module
- Backend — what a plugin brings along on install