Purpose

The contract for plugins that own database schema. A plain Plugin runs register() per request and nothing else, which suits stateless plugins (event subscribers, editor modules, nav builders). A LifecyclePlugin adds install() and uninstall() for one-shot work that must run exactly once on install and reverse on removal: declaring iManager category fields, seeding default rows, dropping schema entries.

The framework never calls these hooks itself. The operator drives them through bin/scriptor plugin:install and bin/scriptor plugin:uninstall, and Scriptor records installed state in data/plugin-states.json. Added in Scriptor 2.1.0.

FQCN + file path

When to use

Implement LifecyclePlugin (instead of plain Plugin) only when the plugin needs real iManager fields: keys that must be queryable, FTS-indexed, or visible in the editor's field filter. Those fields have to be created on install and removed on uninstall.

When per-page values can live as JSON-blob keys in the item's data bag (the common case), a plain Plugin is enough and you do not need this interface at all. The trade-off between the two is covered in the cookbook recipe linked below.

Surface

LifecyclePlugin extends Plugin, so it also requires name(), version(), and register() (see Plugin). It adds two methods:

public function install(PluginContext $context): void

One-shot setup. Invoked by bin/scriptor plugin:install <package>. Should be idempotent: use FieldRepository->ensure() and CategoryRepository->ensure() so a re-run (plugin:install --force) adds new schema without disturbing existing entries.

public function uninstall(PluginContext $context): void

The inverse. Invoked by bin/scriptor plugin:uninstall <package>. Convention: remove the schema entries install() created but leave row values in items.data alone, so a later reinstall finds them. Read $context->purgeDataRequested (set by the CLI's --purge-data flag) to decide whether to also strip those values. Tolerate already-missing entries; a field already gone is a successful no-op.

Lifecycle

register() runs on every request, the same as for a plain Plugin. install() and uninstall() run only when the operator invokes the CLI, never automatically. So a freshly composer required lifecycle plugin is discovered and its register() fires, but its fields do not exist until plugin:install runs. Document that step in the plugin's README.

install() runs after the iManager container is up, in isolation. It must not subscribe to events or register modules or menu items (those belong in register(), which runs per request); a subscription made in install() would never fire afterward. On success the CLI records the package in data/plugin-states.json; if install() throws, the state file is left unchanged so the operator can fix and retry. An operator who runs composer remove without plugin:uninstall first leaves an orphan state entry, cleared by bin/scriptor plugin:cleanup-orphan.

Common patterns

Register and remove category fields

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 only (form events, nav, ...)
    }

    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);
            }
        }
    }
}

The operator workflow

composer require acme/products
bin/scriptor plugin:install acme/products      # runs install()

bin/scriptor plugin:uninstall acme/products    # runs uninstall()
composer remove acme/products

See also