Problem

You want extra inputs on the existing page edit form (SEO meta fields, a price, a "featured" flag), saved alongside the page and readable later from the theme. You do not want to fork PagesModule or stand up a separate editor screen for it; the fields belong inline, under Content or wherever they read best.

Recipe

Two PSR-14 events do the whole job. PageFormRendering fires once while the form renders; you appendHtml() your <input> markup into a named slot. PageSaving fires on submit; you mergeData() the posted values, and PagesModule writes them into the page item's data bag for you. A plain stateless Plugin is enough; no schema migration, no lifecycle hooks.

namespace Acme\PageMeta;

use Imanager\Http\Request;
use Scriptor\Boot\Events\Editor\PageFormRendering;
use Scriptor\Boot\Events\Editor\PageSaving;
use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    public function name(): string    { return 'acme/page-meta'; }
    public function version(): string { return '1.0.0'; }

    public function register(PluginContext $context): void
    {
        $context->subscribe(PageFormRendering::class, [$this, 'onForm']);
        $context->subscribe(PageSaving::class,        [$this, 'onSave']);
    }

    public function onForm(PageFormRendering $event): void
    {
        // $event->page is null on the new-page flow; read the
        // current value defensively. Page::__get resolves data-bag
        // keys, so $page->meta_title returns what a previous save
        // stored under that key.
        $current = (string) ($event->page?->meta_title ?? '');

        $event->appendHtml(
            '<div class="form-control">'
          . '<label for="meta-title">Meta title</label>'
          . '<input id="meta-title" name="meta_title" type="text" value="'
          . htmlspecialchars($current, ENT_QUOTES) . '">'
          . '</div>',
            PageFormRendering::SLOT_AFTER_CONTENT,
        );
    }

    public function onSave(PageSaving $event): void
    {
        $event->mergeData([
            'meta_title' => trim($event->input->postString('meta_title')),
        ]);
    }
}

Read it back in a theme template with $site->page->meta_title. That is the entire round trip: render an input, save its value, read it on the frontend.

Four pieces worth flagging:

  • Ten named slots, one per core field. SLOT_AFTER_NAME, _MENU_TITLE, _SLUG, _CONTENT, _IMAGES, _PARENT, _TEMPLATE, _POSITION, _PUBLISHED, and SLOT_END. PagesModule prints each slot's buffer right after the matching built-in field, so SEO inputs land under Content, a publish-date picker under Published, and so on. appendHtml($html) with no slot argument defaults to SLOT_END.
  • mergeData() writes to the JSON data bag, not a column. The keys you merge are stored inside the item's data payload (one JSON column on the items table). Zero schema change, zero migration, and the keys are plugin-private. The trade-off: they are not their own columns, so iManager's FTS index and the editor's field filter do not see them. When you need that, see the Promote to real iManager fields variant below.
  • You own your HTML escaping. The slot buffer is printed verbatim; appendHtml() does not re-encode it. Escape attribute values with htmlspecialchars(..., ENT_QUOTES) on the way in, exactly as the snippet does. On save, route free text through iManager's Sanitizer (or at least trim()); mergeData() stores whatever you hand it.
  • Match the POST name to the data key. The <input name="…"> and the mergeData(['…' => …]) key are the same string by convention (meta_title here); that is what makes $page->meta_title resolve later. There is no framework magic linking them, so keep them in sync yourself.

Variants

Hide a core field for a page-type

A page-type that does not use every built-in field can suppress the ones it does not need. PageFormRendering::hide($field) marks a core field hidden; PagesModule skips rendering it. Gate on the page's template so only your type is affected:

public function onForm(PageFormRendering $event): void
{
    if ($event->page?->template !== 'product') {
        return;
    }

    // Products are catalog children, not main-nav items.
    $event->hide('menu_title');

    $event->appendHtml(
        '<div class="form-control"><label for="price">Price</label>'
      . '<input id="price" name="price" type="text" value="'
      . htmlspecialchars((string) ($event->page?->price ?? ''), ENT_QUOTES)
      . '"></div>',
        PageFormRendering::SLOT_AFTER_SLUG,
    );
}

Hideable core fields: name, menu_title, slug, content, images, parent, template, position, published. A hidden field posts no value, so its stored data is carried forward untouched on save; hiding a field never wipes it. Why hide instead of override? Core fields are read from $_POST after the event fires, so mergeData() cannot change them. To replace a core field with your own widget, hide() it and re-inject an input under the same name.

Promote to real iManager fields

When the keys need to be queryable, FTS-indexed, or visible in the editor's field filter, JSON-blob storage is not enough. Declare them as real iManager category fields. That needs a LifecyclePlugin so the fields are created on install and removed on uninstall. The form/save half of this recipe stays identical; only the storage backing changes. See Register schema with a LifecyclePlugin.

See also

  • Register schema with a LifecyclePlugin: the upgrade path when JSON-blob keys need to become real, queryable fields
  • Editor module with CRUD sub-routes: the other way to add admin surface, a separate screen the plugin owns, rather than fields on the existing page form
  • Concept: Editor extensions: the narrative home of the PageFormRendering / PageSaving events (slots, appendHtml(), hide()/isHidden(), mergeData()) and how they sit beside modules and the container
  • Concept: Events: the PSR-14 dispatch model both events ride on
  • Page: the DTO $event->page hands you; __get is what makes $page->meta_title resolve a data-bag key