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, andSLOT_END.PagesModuleprints 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 toSLOT_END. mergeData()writes to the JSONdatabag, not a column. The keys you merge are stored inside the item'sdatapayload (one JSON column on theitemstable). 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 withhtmlspecialchars(..., ENT_QUOTES)on the way in, exactly as the snippet does. On save, route free text through iManager'sSanitizer(or at leasttrim());mergeData()stores whatever you hand it. - Match the POST name to the data key. The
<input name="…">and themergeData(['…' => …])key are the same string by convention (meta_titlehere); that is what makes$page->meta_titleresolve 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/PageSavingevents (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->pagehands you;__getis what makes$page->meta_titleresolve a data-bag key