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
- Inline JSON endpoint in the theme: the no-plugin version when you have one or two endpoints
- Controller-with-DI via factory closure: for routes that dispatch to a class instead of a closure
- Auth middleware on a custom route: gate selected routes behind a login or API key check
- Build a Module: Theme Integration:
the full walkthrough of the
_ext.phphook against the hand-writtenbigins/scriptor-simple-routermini-case - Concept: Frontend events:
why this recipe hooks
_ext.phpand notPageResolving Plugin+PluginContext: the minimum surface the plugin satisfies for discovery- nikic/fast-route and thephpleague/route upstream