Problem
A plugin needs an admin section: list rows, create new ones, edit
them, delete them. You want all four pages to live under a single
slug (/editor/<slug>/...), share the editor's auth gate, and
flow through the same chrome (sidebar, header, breadcrumbs,
flash) as the built-in pages and profile modules.
Recipe
Register one factory under one slug; let the module dispatch
sub-routes itself based on $editor->urlSegments. The router
only routes the first segment to the module; everything after
that lands in segments 1+ and the module pattern-matches them.
namespace Acme\TeamEditor;
use League\Container\Container;
use Scriptor\Boot\Editor\Editor;
use Scriptor\Boot\Editor\Module;
final class TeamModule implements Module
{
public function __construct(
private readonly Container $container,
private readonly Editor $editor,
) {}
public function execute(): void
{
$this->editor->pageTitle = 'Team';
$this->editor->breadcrumbs =
'<a href="' . $this->editor->siteUrl . '/">Editor</a> › Team';
$action = $this->editor->urlSegments->at(1) ?? 'index';
match ($action) {
'index' => $this->renderIndex(),
'create' => $this->handleCreate(),
'edit' => $this->handleEdit((int) ($this->editor->urlSegments->at(2) ?? 0)),
'delete' => $this->handleDelete((int) ($this->editor->urlSegments->at(2) ?? 0)),
default => $this->editor->redirect($this->editor->siteUrl . '/team/', 303),
};
}
private function renderIndex(): void
{
$rows = $this->container->get(TeamRepository::class)->all();
$html = '<a href="' . $this->editor->siteUrl . '/team/create/" class="btn btn-primary">New member</a>';
$html .= '<table><tbody>';
foreach ($rows as $r) {
$html .= sprintf(
'<tr><td>%s</td>'
. '<td><a href="%s/team/edit/%d/">Edit</a></td>'
. '<td><form method="post" action="%s/team/delete/%d/" onsubmit="return confirm(\'Delete?\')">'
. '<input type="hidden" name="csrf" value="%s">'
. '<button>Delete</button></form></td></tr>',
$this->editor->sanitizer->entities($r->name),
$this->editor->siteUrl, $r->id,
$this->editor->siteUrl, $r->id,
$this->editor->csrfQueryString('team_delete'),
);
}
$html .= '</tbody></table>';
$this->editor->pageContent = $html;
}
private function handleCreate(): void
{
if ($this->editor->input->method() === 'POST') {
$name = $this->editor->sanitizer->text(
$this->editor->input->post('name', ''),
);
if ($name === '') {
$this->editor->addMsg('error', 'Name is required.');
} else {
$this->container->get(TeamRepository::class)->insert($name);
$this->editor->flashMsg('success', 'Member created.');
$this->editor->redirect($this->editor->siteUrl . '/team/', 303);
}
}
$this->editor->pageContent = $this->renderForm(null, '');
}
private function handleEdit(int $id): void
{
$row = $this->container->get(TeamRepository::class)->find($id);
if ($row === null) {
$this->editor->flashMsg('error', 'Member not found.');
$this->editor->redirect($this->editor->siteUrl . '/team/', 303);
}
if ($this->editor->input->method() === 'POST') {
$name = $this->editor->sanitizer->text(
$this->editor->input->post('name', ''),
);
if ($name === '') {
$this->editor->addMsg('error', 'Name is required.');
} else {
$this->container->get(TeamRepository::class)->rename($id, $name);
$this->editor->flashMsg('success', 'Member saved.');
$this->editor->redirect($this->editor->siteUrl . '/team/', 303);
}
}
$this->editor->pageContent = $this->renderForm($id, $row->name);
}
private function handleDelete(int $id): void
{
if ($this->editor->input->method() !== 'POST') {
$this->editor->redirect($this->editor->siteUrl . '/team/', 303);
}
// CSRF check via $this->editor->input->post('csrf', '') goes here.
$this->container->get(TeamRepository::class)->remove($id);
$this->editor->flashMsg('success', 'Member deleted.');
$this->editor->redirect($this->editor->siteUrl . '/team/', 303);
}
private function renderForm(?int $id, string $name): string
{
$action = $this->editor->siteUrl . '/team/' . ($id === null ? 'create/' : 'edit/' . $id . '/');
return sprintf(
'<form method="post" action="%s">'
. '<label>Name <input name="name" value="%s" required></label>'
. '<button>%s</button>'
. '</form>',
$this->editor->sanitizer->entities($action),
$this->editor->sanitizer->entities($name),
$id === null ? 'Create' : 'Save',
);
}
}
Register it from Plugin::register():
$context->registerEditorModule(
'team',
static fn (Container $c, Editor $editor) => new TeamModule($c, $editor),
);
Four pieces worth flagging:
urlSegments->at(1)is the action slug. Segment 0 is your module's own slug (team); the router strips it from your perspective only conceptually:at(0)still returnsteam, andat(1)is the first sub-segment. The?? 'index'default handles/editor/team/(no second segment) as the list view.- 303 redirects on every POST that succeeded. The PRG
pattern Scriptor's frontend Site recommends applies in the
editor too: a 303 prevents the form from re-submitting if the
user reloads, and pairs with
flashMsg()so the success message survives the redirect. Same rationale, same shape, just on the admin side. addMsg()for in-request errors,flashMsg()for redirects. The form re-renders inside the same request (validation failed, no redirect) soaddMsg()is right; the success path redirects to the list, soflashMsg()is right. Mixing them swaps the flash queue between requests in a way that's easy to debug wrong.- CSRF on POST is the module's responsibility. Scriptor's
Editor surface gives you
csrfQueryString($name)for the form side; verifying the submitted token against the session bag is module-internal. The bundledPagesModuleis the reference for the verification shape (searcherror_csrf_token_mismatch).
Variants
POST-action discriminator (single-URL pattern)
The built-in PagesModule uses a different shape: every route
lives under /editor/pages/, and a hidden action form field
selects the handler. It is denser (no sub-segments to parse) but
loses the URL-bookmarkable structure:
public function execute(): void
{
$action = $this->editor->input->postString('action');
if ($action !== '') {
match ($action) {
'create-member' => $this->handleCreate(),
'edit-member' => $this->handleEdit(),
'delete-member' => $this->handleDelete(),
};
return;
}
$this->renderIndex();
}
Pick the sub-route shape (/edit/<id>/) for modules whose pages
benefit from being shareable URLs; pick the POST-action shape
for modules where the operations are tightly coupled to one
landing page anyway.
Module that loads its own CSS / JS
Modules need their own stylesheet often enough that the Editor
surface exposes addResource() for it:
public function execute(): void
{
$this->editor->addResource(
'css',
'<link rel="stylesheet" href="' . $this->editor->assetUrl('plugins/team/style.css') . '">',
'head',
);
$this->editor->addResource(
'js',
'<script defer src="' . $this->editor->assetUrl('plugins/team/main.js') . '"></script>',
'body',
);
// ... dispatch as before
}
The asset path is relative to the editor's asset root; ship the files inside your plugin and let the install script symlink them in.
See also
- Plugin contributes a sidebar menu item:
the natural follow-up; a module without a
MenuItemis reachable by URL but invisible in the chrome - Auth middleware on a custom route:
the inverse case (custom frontend route gated behind editor
login); this recipe inherits the gate because the URL is under
/editor/ Editor: the instance every module receives (pageContent,addMsg,flashMsg,redirect,urlSegments,sanitizer,input)Module: the one-method interface this recipe implementsPluginContext:registerEditorModule()is the registration entry point and factory shape- Concept: Editor extensions: the narrative version of how modules, menu items, and the container fit together