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> &rsaquo; 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 returns team, and at(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) so addMsg() is right; the success path redirects to the list, so flashMsg() 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 bundled PagesModule is the reference for the verification shape (search error_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 MenuItem is 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 implements
  • PluginContext: 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