Problem
Your plugin stores per-page values, and you have outgrown the
JSON-blob data bag: you need the keys to be real iManager
category fields so they are queryable, picked up by FTS, and
visible in the editor's field filter. Those fields must be created
when the plugin is installed and removed when it is uninstalled,
without the operator hand-editing the schema.
Recipe
Implement Scriptor\Boot\Plugin\LifecyclePlugin (it extends the
plain Plugin). Beyond register(), you add install() and
uninstall(). install() declares fields with
FieldRepository->ensure(); uninstall() deletes them. The
framework never calls these itself. The operator runs
bin/scriptor plugin:install and plugin:uninstall, and Scriptor
records the installed state in data/plugin-states.json.
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;
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 stays here (form events, nav, etc.).
// install()/uninstall() must NOT subscribe — they run once.
}
public function install(PluginContext $context): void
{
$cats = $context->container()->get(CategoryRepository::class);
$fields = $context->container()->get(FieldRepository::class);
$pagesId = $cats->findBySlug('pages')->id;
// ensure() is insert-on-miss, return-existing-on-hit, keyed
// by (categoryId, name) — so install() is safe to re-run.
$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: also strip these keys'
// values from items.data (typically a direct SQL UPDATE).
}
}
}
The operator workflow is two steps each way:
composer require acme/products
bin/scriptor plugin:install acme/products # runs install(), records state
bin/scriptor plugin:uninstall acme/products # runs uninstall(), clears state
composer remove acme/products
Four pieces worth flagging:
- The framework never auto-invokes the hooks. Installing the
composer package only makes the class discoverable;
register()runs per request as usual, but the fields do not exist until the operator runsplugin:install. This is deliberate: schema changes are an operator decision, not a side effect ofcomposer require. Document the install step in your plugin's README. ensure()makesinstall()idempotent. It inserts the field if missing and returns the existing one otherwise, keyed by(categoryId, name). Re-running install (plugin:install --forceafter a schema-adding update) adds new fields without disturbing existing ones.uninstall()should be just as forgiving: a field already gone is a successful no-op, not an error.install()must not subscribe to events or register modules. It runs exactly once, in isolation; a subscription made there would never fire afterward. Event subscriptions, editor modules, and menu items all belong inregister(), which runs every request.install()is only for one-shot schema and seed work.- Data is preserved by default;
--purge-datais opt-in. A plainplugin:uninstallremoves the field definitions but leaves the per-row values initems.data, on the assumption the operator may reinstall.plugin:uninstall --purge-datasets$context->purgeDataRequested = true, and youruninstall()body decides whether to also wipe those values. If the operator skips the CLI and just runscomposer remove, the state entry is orphaned, andbin/scriptor plugin:cleanup-orphanclears it.
Variants
Stay stateless with JSON-blob keys
If you do not need the fields to be queryable or FTS-indexed, you
do not need a LifecyclePlugin at all. A plain Plugin that
mergeData()s keys into the page's data bag needs no install
step and no schema. That is the simpler default; reach for the
lifecycle only when JSON-blob is genuinely not enough. See
Add fields to the page edit form.
| JSON-blob (stateless) | Real fields (lifecycle) | |
|---|---|---|
| Schema change | none | install() declares fields |
| Operator step | none | plugin:install |
| Queryable / FTS | no | yes |
| Editor field filter | no | yes |
Seed default rows or a whole category
install() is the place for any one-shot setup, not just page
fields. With the container's repositories you can declare a new
category and seed default items:
public function install(PluginContext $context): void
{
$cats = $context->container()->get(CategoryRepository::class);
$items = $context->container()->get(\Imanager\Storage\ItemRepository::class);
$category = $cats->ensure(/* a new category definition */);
// $items->save(...) to insert default rows under it.
}
Reverse each piece in uninstall() in the opposite order (rows,
then fields, then the category), and keep every step tolerant of
already-removed entries.
See also
- Add fields to the page edit form: the form/save half, identical whether the keys are JSON-blob or real fields; this recipe only changes the storage backing
- Custom PSR-14 event for plugin-to-plugin communication:
another piece of plugin wiring that lives in
register(), not the lifecycle hooks Plugin: the base interface;LifecyclePluginextends it withinstall()/uninstall()PluginContext: the handle both hooks receive, exposingcontainer()and thepurgeDataRequestedflag- Concept: Plugin discovery: how Scriptor finds plugins and where the install state lives
- Build a Module: the tutorial that walks a stateless plugin end-to-end; this recipe is the schema upgrade on top of that base