Problem

You want to mount a handful of REST endpoints or custom URLs alongside Scriptor's page tree, and you want FastRoute's matcher (or league/route's middleware chain) instead of writing your own.

Recipe

The shape is the same one the Build a Module tutorial walks through for its hand-written matcher: a plugin that owns a Router, a routes.php next to the theme, and one line in the theme's _ext.php. The only piece that changes is the matcher.

Drop the matcher in by composing FastRoute inside the plugin's Router class:

// src/Router.php  (plugin: acme/scriptor-fastroute)

namespace Acme\ScriptorFastRoute;

use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use function FastRoute\simpleDispatcher;

final class Router
{
    private static ?self $instance = null;
    /** @var array<int, array{0: string, 1: string, 2: callable|array}> */
    private array $routes = [];

    public static function instance(): self
    {
        return self::$instance ??= new self();
    }

    public function get(string $pattern, callable|array $handler): void
    {
        $this->routes[] = ['GET', $pattern, $handler];
    }

    public function post(string $pattern, callable|array $handler): void
    {
        $this->routes[] = ['POST', $pattern, $handler];
    }

    public static function handle(): bool
    {
        $self = self::instance();

        $dispatcher = simpleDispatcher(static function (RouteCollector $r) use ($self): void {
            foreach ($self->routes as [$method, $pattern, $handler]) {
                $r->addRoute($method, $pattern, $handler);
            }
        });

        $result = $dispatcher->dispatch(
            $_SERVER['REQUEST_METHOD'] ?? 'GET',
            rawurldecode(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/'),
        );

        if ($result[0] !== Dispatcher::FOUND) {
            return false;  // let Scriptor's page resolver continue
        }

        [, $handler, $vars] = $result;
        $response = $handler($vars);

        if (is_array($response) || is_object($response) && ! method_exists($response, '__toString')) {
            header('Content-Type: application/json');
            echo json_encode($response, JSON_THROW_ON_ERROR);
        } else {
            echo (string) $response;
        }
        return true;
    }
}

Then the plugin's composer.json declares the dependency and the manifest the discovery layer expects:

{
    "name": "acme/scriptor-fastroute",
    "type": "scriptor-plugin",
    "require": {
        "php": "^8.2",
        "nikic/fast-route": "^2.0"
    },
    "autoload": { "psr-4": { "Acme\\ScriptorFastRoute\\": "src/" } },
    "extra": { "scriptor": { "plugin": "Acme\\ScriptorFastRoute\\Plugin" } }
}

src/Plugin.php can be empty for this case (no editor module, no listener, no nav contribution):

namespace Acme\ScriptorFastRoute;

use Scriptor\Boot\Plugin\Plugin as ScriptorPlugin;
use Scriptor\Boot\Plugin\PluginContext;

final class Plugin implements ScriptorPlugin
{
    public function register(PluginContext $context): void {}
    public function version(): string { return '0.1.0'; }
}

The site declares routes in a file next to the theme:

// themes/<your-theme>/routes.php

use Acme\ScriptorFastRoute\Router;

$router = Router::instance();

$router->get('/api/users/{id:\d+}', static fn(array $vars) => [
    'id'   => (int) $vars['id'],
    'name' => 'Ada Lovelace',
]);

And the theme's _ext.php loads routes + bridges to the live request:

// themes/<your-theme>/_ext.php

require_once __DIR__ . '/routes.php';

if (\Acme\ScriptorFastRoute\Router::handle()) return;

$site = new \Scriptor\Boot\Frontend\Site(
    \Scriptor\Boot\App::container(),
    $config,
    dirname(__DIR__, 2),
);
$site->execute();

The return from _ext.php is what tells Scriptor's front controller "we are done"; the Site::execute() fall-through is the page-resolver path for everything the router did not claim. Both halves of that contract are explained in the Build a Module tutorial's Theme Integration chapter.

Variants

league/route instead of FastRoute

The shape is identical; swap the matcher:

use League\Route\Router as LeagueRouter;

$leagueRouter = new LeagueRouter();
$leagueRouter->map('GET', '/api/users/{id}', static fn($req, $args) =>
    new \Laminas\Diactoros\Response\JsonResponse([
        'id'   => (int) $args['id'],
        'name' => 'Ada Lovelace',
    ]),
);

$response = $leagueRouter->dispatch(
    \Laminas\Diactoros\ServerRequestFactory::fromGlobals(),
);
(new \Laminas\HttpHandlerRunner\Emitter\SapiEmitter())->emit($response);

league/route carries a PSR-7 / PSR-15 chain, which buys you middleware groups, dependency injection per route, and route strategies. The trade is two extra dependencies (laminas/laminas-diactoros for the PSR-7 implementation, laminas/laminas-httphandlerrunner for the emitter) and a heavier mental model.

Cache ordering

If your theme runs a SuperCache short-circuit at the top of _ext.php, decide whether the router runs before or after the cache check. Same trade-off as the Build a Module tutorial discusses for the hand-written matcher. The recommendation does not change: router before cache for most sites; cache before router only if you have a cached-heavy frontend with no POST endpoints.

See also