Scriptor 2 ships two kinds of plugins:
-
Stateless plugins implement
Scriptor\Boot\Plugin\Plugin. They subscribe to events, register editor modules, contribute nav items. They own no DB state and need no lifecycle:register()runs once per request, that's it. -
Lifecycle plugins implement
Scriptor\Boot\Plugin\LifecyclePlugin(which extendsPlugin). They additionally declareinstall()anduninstall()— one-shot setup and teardown for plugins that register iManager category fields, seed default items, or do other work that must run exactly once on install and reverse on removal.
The framework never auto-invokes lifecycle hooks. The operator drives
them through bin/scriptor plugin:install and bin/scriptor plugin:uninstall.
bin/scriptor plugin:list # Inventory + state
bin/scriptor plugin:install <package> # Invoke install(), mark state
bin/scriptor plugin:uninstall <package> # Invoke uninstall(), clear state
bin/scriptor plugin:cleanup-orphan # Recovery: state without code
Read-only inventory: lists every discovered scriptor-plugin composer
package together with the row in data/plugin-states.json (if any).
Four meaningful states:
| KIND | STATUS | Meaning |
|---|---|---|
| stateless | discovered | Plain Plugin, no lifecycle work |
| lifecycle | pending install | LifecyclePlugin not yet plugin:installed |
| lifecycle | installed | LifecyclePlugin tracked in data/plugin-states.json |
| lifecycle | ORPHAN | State entry without a discovered package (composer-removed) |
Safe before bin/scriptor install on a fresh checkout — no DB access.
Bootstraps the iManager container, instantiates the plugin's class,
calls LifecyclePlugin::install(PluginContext), and records the package
in data/plugin-states.json on success.
bin/scriptor plugin:install studenten-frankfurt/katalog
bin/scriptor plugin:install --all # Every pending lifecycle plugin
bin/scriptor plugin:install --force ... # Re-run install() on an installed packageIf install() throws, the state file stays unchanged. Operator fixes
the failure and retries — no stale "installed" marker.
Invokes LifecyclePlugin::uninstall(PluginContext) while the plugin's
class is still loadable, then removes the package from state.json.
After the CLI succeeds, the operator runs composer remove <package>
to drop the code itself.
bin/scriptor plugin:uninstall studenten-frankfurt/katalog
bin/scriptor plugin:uninstall --purge-data ... # also wipe row values
bin/scriptor plugin:uninstall --force-state-clear # escape hatchData preservation is the default: the plugin's uninstall() body
typically removes schema entries (field definitions, custom categories)
but leaves the per-row JSON values in items.data alone, on the
assumption that the operator might reinstall later. Pass --purge-data
to forward purgeDataRequested = true on the PluginContext — the
plugin's body then chooses to also strip those values from items.
When the operator forgets the CLI and runs composer remove <package>
directly, the package's code is gone but its state entry stays. The
package shows up as ORPHAN in plugin:list. plugin:cleanup-orphan
without arguments lists every orphan; with a <package> argument it
drops that single entry from state.json.
It does not touch DB schema entries the orphan plugin registered —
without the plugin's code we don't know what those entries were. The
only automatic cleanup path is: composer require the plugin back,
then run a clean plugin:uninstall. Manual SQL is the alternative.
A minimal LifecyclePlugin looks like:
namespace Acme\Products;
use Imanager\Domain\Field;
use Imanager\Storage\CategoryRepository;
use Imanager\Storage\FieldRepository;
use Scriptor\Boot\Plugin\LifecyclePlugin;
use Scriptor\Boot\Plugin\PluginContext;
use Scriptor\Boot\Events\Editor\PageFormRendering;
use Scriptor\Boot\Events\Editor\PageSaving;
final class Plugin implements LifecyclePlugin
{
public function name(): string { return 'acme/products'; }
public function version(): string { return '1.0.0'; }
public function register(PluginContext $context): void
{
// Per-request wiring (subscribe to form events, etc.)
$context->subscribe(PageFormRendering::class, [$this, 'onFormRendering']);
$context->subscribe(PageSaving::class, [$this, 'onSaving']);
}
public function install(PluginContext $context): void
{
$cats = $context->container()->get(CategoryRepository::class);
$fields = $context->container()->get(FieldRepository::class);
$pagesId = $cats->findBySlug('pages')->id;
$fields->ensure(Field::text($pagesId, 'price', 'Price')->position(20));
$fields->ensure(Field::text($pagesId, 'deposit', 'Deposit')->position(21));
}
public function uninstall(PluginContext $context): void
{
$cats = $context->container()->get(CategoryRepository::class);
$fields = $context->container()->get(FieldRepository::class);
$pagesId = $cats->findBySlug('pages')->id;
foreach (['price', 'deposit'] as $name) {
$field = $fields->findByName($pagesId, $name);
if ($field !== null && $field->id !== null) {
$fields->delete($field->id);
}
}
if ($context->purgeDataRequested) {
// Operator passed --purge-data. Wipe row values for the
// fields above from items.data. Implementation omitted
// here — typically a direct SQL UPDATE removing the JSON
// keys per row.
}
}
// ...event handlers...
}install() runs after the iManager container is up (schema migrations
applied, repositories registered). Safe operations:
FieldRepository->ensure(...)to declare category fieldsCategoryRepository->ensure(...)to declare new categoriesItemRepository->save(...)to seed default rows
What it must not do:
- Subscribe to events — that's
register()'s job; install runs once, the subscription would never fire after install completes. - Register editor modules / menu items — same reason.
- Anything depending on plugin boot order — install runs in isolation.
- Reverse the schema work from
install()(delete fields, delete categories, delete seed rows). - Read
$context->purgeDataRequestedand, if true, also strip the row values the plugin owned fromitems.data. - Not throw on missing entries — if a field is already gone, that's a successful no-op, not an error.
The intended operator workflow:
Install a new lifecycle plugin:
composer require acme/products
bin/scriptor plugin:install acme/productsUpdate a lifecycle plugin:
composer update acme/products
# version() string may change; nothing else to do automatically.
# If install() needs to re-run for schema additions, plugin author
# documents that — typically:
bin/scriptor plugin:install acme/products --forceUninstall a lifecycle plugin:
bin/scriptor plugin:uninstall acme/products
composer remove acme/productsRecovery (composer-removed without plugin:uninstall):
bin/scriptor plugin:list # See the ORPHAN row
bin/scriptor plugin:cleanup-orphan acme/products
# (optionally) clean schema entries manuallyComposer scripts and Composer plugins are deliberately not used here.
The trade-off was: a pre-package-uninstall script could refuse to
let composer remove a not-yet-uninstalled plugin (a "guard"), but
composer remove --no-scripts bypasses it, and the friction for the
95% case (operator who follows the workflow) is not worth the 5%
case (operator who skips it anyway). The CLI plus this document are
the contract.
data/plugin-states.json:
{
"studenten-frankfurt/katalog": {
"version": "0.1.0",
"installed_at": 1748419200
},
"acme/products": {
"version": "1.0.0",
"installed_at": 1748419500
}
}version: the composer package version recorded at install time.installed_at: unix timestamp of the install.
The file lives in data/, which is gitignored, so a fresh checkout
starts with no state file. The state manager treats a missing file as
"no lifecycle plugins installed" — plugin:list works, and
plugin:install creates the file on first call.