Purpose

The contract a plugin satisfies to mount its own admin surface under /editor/<slug>/.... One method: execute(). The router instantiates the module per request via its registered factory, calls execute(), and trusts the module to write into the editor's layout slots.

The interface is intentionally tiny. A module is a per-request controller that reads $editor->input, writes $editor->pageContent, and is done. Anything richer (sub-routes, controllers, middleware) is the module's own internal structure.

FQCN + file path

When to use

You implement Module once per editor surface a plugin adds. The class lives in your plugin's src/; you register its factory (not the class) via PluginContext::registerEditorModule($slug, $factory) from inside Plugin::register().

Plugins usually pair a Module with a MenuItem so the new slug also appears in the sidebar. The two are independent: a module without a menu item is reachable by URL but invisible in the chrome; a menu item without a module is a dead link.

Surface

interface Module
{
    public function execute(): void;
}

One method, no return value. Side effects on the Editor instance the module received (through its constructor or factory) are the contract.

A module typically writes to:

  • $editor->pageTitle: <title> and H1 text.
  • $editor->pageContent: the rendered HTML body.
  • $editor->breadcrumbs: the breadcrumb HTML.
  • $editor->addMsg() / flashMsg(): message queue.
  • $editor->redirect(): when a POST succeeded.
  • $editor->addResource(): module-specific CSS / JS.

Lifecycle

PluginContext::registerEditorModule($slug, $factory) records the factory in ModuleRegistry. On every request, EditorRouter parses the URL, looks up the slug, calls the factory once the auth gate has passed, and dispatches execute().

The factory signature:

function(Container $container, Editor $editor): Module

The router constructs a fresh module per request. Anything you need to persist across requests goes through a service bound into the container (and read from there in the factory), not through a static property on the module.

Re-registering an existing slug replaces the previous factory. Last writer wins. In practice plugins use unique slugs (bigins/blog registers 'blog').

Common patterns

Minimal module + factory + plugin registration

namespace Acme\Hello\Editor;

use League\Container\Container;
use Scriptor\Boot\Editor\Editor;
use Scriptor\Boot\Editor\Module;

final class HelloModule implements Module
{
    public function __construct(
        private Container $container,
        private Editor $editor,
    ) {}

    public function execute(): void
    {
        $this->editor->pageTitle   = 'Hello';
        $this->editor->pageContent = '<p>Hello from the Hello plugin.</p>';
    }
}
// In your Plugin::register():
$context->registerEditorModule(
    'hello',
    static fn(Container $c, Editor $editor) => new HelloModule($c, $editor),
);

Module with a POST handler + flash + redirect

public function execute(): void
{
    $this->editor->pageTitle = 'Settings';

    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->settings->save('name', $name);
            $this->editor->flashMsg('success', 'Settings saved.');
            $this->editor->redirect($this->editor->siteUrl . '/settings/', 303);
        }
    }
    $this->editor->pageContent = $this->renderForm();
}

Sub-routes inside one module

The framework only routes the first segment to a module. Deeper paths land in $editor->urlSegments and the module dispatches them itself:

public function execute(): void
{
    $action = $this->editor->urlSegments->at(1) ?? 'index';
    match ($action) {
        'index'  => $this->renderIndex(),
        'edit'   => $this->renderEdit(),
        'delete' => $this->handleDelete(),
        default  => $this->editor->redirect($this->editor->siteUrl . '/my-module/', 303),
    };
}

See also