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 runs plugin:install. This is deliberate: schema changes are an operator decision, not a side effect of composer require. Document the install step in your plugin's README.
  • ensure() makes install() idempotent. It inserts the field if missing and returns the existing one otherwise, keyed by (categoryId, name). Re-running install (plugin:install --force after 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 in register(), which runs every request. install() is only for one-shot schema and seed work.
  • Data is preserved by default; --purge-data is opt-in. A plain plugin:uninstall removes the field definitions but leaves the per-row values in items.data, on the assumption the operator may reinstall. plugin:uninstall --purge-data sets $context->purgeDataRequested = true, and your uninstall() body decides whether to also wipe those values. If the operator skips the CLI and just runs composer remove, the state entry is orphaned, and bin/scriptor plugin:cleanup-orphan clears 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