This chapter writes the Router. It is the only "interesting" class in the plugin and the only one that survives unchanged from this point onwards. Everything later is wiring: chapter 4 wraps it in Request and Response objects, chapter 5 hooks it into Scriptor's request pipeline, chapter 6 tests and ships it.
The Router itself is pure PHP. It has no dependency on Scriptor, on
$_SERVER, on Composer, on HTTP. It is a list of routes and an
algorithm that says "given this path and method, which one
matches?" That isolation is what makes chapter 6's tests cheap.
What you'll write
Two files under src/:
Route.php, an immutable value object representing one registered route: method, pattern, handler, plus the compiled regex and ordered list of parameter names.Router.php, the registry + matcher itself, with a staticinstance()accessor so other classes (theme_ext.php, plugin glue) can reach the singleton without dragging the container around.
After this chapter you can:
$r = Router::instance();
$r->get('/api/users/{id}', $someHandler);
$match = $r->match('/api/users/42', 'GET');
// $match = ['route' => Route{...}, 'params' => ['id' => '42']]
The $someHandler is stored verbatim; chapter 4 makes it callable
with a Request and turns its return value into a Response. For
now we only care that the match table works.
The Route value object
Create src/Route.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter;
final readonly class Route
{
/**
* @param string $method HTTP method, uppercase.
* @param string $pattern Raw pattern, e.g. '/api/users/{id}'.
* @param mixed $handler Closure, FQCN string, or [class, method].
* Stored as-is; chapter 4 resolves it.
* @param string $regex Compiled regex, ready for preg_match.
* @param list<string> $paramNames Path-parameter names in order of appearance.
*/
public function __construct(
public string $method,
public string $pattern,
public mixed $handler,
public string $regex,
public array $paramNames,
) {}
}
Five fields, all readonly. Two of them (regex, paramNames) are
derived from $pattern at construction time, so chapter 4 will not
recompile on every request, only when a new route is registered.
The handler is mixed on purpose: the Router does not know what a
"handler" should look like beyond "you can call it later". The
chapter-4 dispatcher narrows that contract.
The Router shell
Create src/Router.php:
<?php
declare(strict_types=1);
namespace Bigins\ScriptorSimpleRouter;
final class Router
{
private static ?self $instance = null;
/** @var list<Route> */
private array $routes = [];
public static function instance(): self
{
return self::$instance ??= new self();
}
public function get(string $pattern, mixed $handler): void
{
$this->add('GET', $pattern, $handler);
}
public function post(string $pattern, mixed $handler): void
{
$this->add('POST', $pattern, $handler);
}
public function put(string $pattern, mixed $handler): void
{
$this->add('PUT', $pattern, $handler);
}
public function delete(string $pattern, mixed $handler): void
{
$this->add('DELETE', $pattern, $handler);
}
private function add(string $method, string $pattern, mixed $handler): void
{
[$regex, $paramNames] = self::compilePattern($pattern);
$this->routes[] = new Route($method, $pattern, $handler, $regex, $paramNames);
}
}
Five things going on:
private static ?self $instance+instance(): classic lazy-init singleton. Routes registered from anywhere in the request reach the same instance. TheRouter::handle()static call you saw in chapter 1 (which chapter 5 implements) leans on this accessor too.private array $routes: a flat list. Order matters: the first matching route wins, so register more-specific patterns before less-specific ones. No tree, no trie. The N here is the number of routes the site declares, typically tens, so linear search is irrelevant.- One method per HTTP verb:
get,post,put,delete. Real-world router libraries cover OPTIONS, PATCH, HEAD too; add them with two lines each if your endpoints need them. The Cookbook will show wrapping a richer library if four verbs are not enough. add()is private: callers go through the verb methods. This keeps the public API small and lets each verb method add its own type narrowing later (ahead()method might forcehandler: null, for example).- Compilation happens on registration, not on match. New
routes pay a one-time regex-compile cost; matching requests
only run
preg_match.
Pattern → regex: compilePattern()
This is the only piece of clever PHP in the plugin. The input is
the human-readable pattern (/api/users/{id}), the output is
something preg_match can run plus the list of parameter names
in declaration order.
Add this method to Router:
/**
* Compile a route pattern like '/api/users/{id}' into a regex
* and capture the parameter names.
*
* @return array{0: string, 1: list<string>} [regex, paramNames]
*/
private static function compilePattern(string $pattern): array
{
$paramNames = [];
$regex = preg_replace_callback(
'#\{([A-Za-z_][A-Za-z0-9_]*)\}#',
static function (array $m) use (&$paramNames): string {
$paramNames[] = $m[1];
return '(?P<' . $m[1] . '>[^/]+)';
},
$pattern,
);
// Anchor and escape the static parts. `preg_quote` would
// also escape the regex we just inserted, so we anchor
// around the already-substituted string instead.
return ['#^' . $regex . '$#', $paramNames];
}
Walk the regex on the input pattern:
\{([A-Za-z_][A-Za-z0-9_]*)\}matches{foo}placeholders. The inner group restricts param names to valid PHP-ish identifiers, which avoids ambiguity if a future version wires the params straight onto a typed handler signature.(?P<id>[^/]+)is a named regex group.[^/]+is "one or more non-slash characters", which is what you want for path segments. Matching across slashes is a separate feature (variable-depth routes); not in scope here.&$paramNamescaptures the names in registration order, so the match step can hand them back as['id' => '42']without re-parsing the pattern.
Static parts and escaping. A pure-
/static/path/route works because nothing in the pattern is a regex special character that trips PHP's PCRE delimiters (#here). For tutorial Simple Router that is fine. A production router runspreg_quoteon the static segments between placeholders; the Cookbook shows the safer version.
The match algorithm
Add match() to Router:
/**
* Match a request against the registered routes.
*
* @return array{route: Route, params: array<string, string>}|null
* The first matching route plus its extracted path
* params, or null when nothing matches.
*/
public function match(string $path, string $method): ?array
{
$method = strtoupper($method);
foreach ($this->routes as $route) {
if ($route->method !== $method) {
continue;
}
if (preg_match($route->regex, $path, $m) !== 1) {
continue;
}
$params = [];
foreach ($route->paramNames as $name) {
$params[$name] = $m[$name];
}
return ['route' => $route, 'params' => $params];
}
return null;
}
The loop is verbatim what it looks like: filter by method, run the regex, on match extract just the named groups (not the numeric ones, which include the whole-pattern match at index 0). Returns the first hit. No backtracking, no second-best, no fallback.
?array return type is intentionally loose. A real codebase would
introduce a MatchedRoute value object with Route plus
array<string, string> and return ?MatchedRoute. We are saving
that lift for the chapter-6 polish; for now the shape is small
enough to read inline.
Verify in isolation
The Router has zero dependency on Scriptor. You can prove it works with five lines of PHP, without ever booting the framework.
Inside the plugin directory, run composer install once. The
plugin's own composer.json has no third-party dependencies, but
composer install still writes a vendor/autoload.php with your
PSR-4 namespace. That is all the script below needs.
cd ~/code/scriptor-simple-router
composer install
Then create verify.php next to composer.json:
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Bigins\ScriptorSimpleRouter\Router;
$r = Router::instance();
$r->get('/api/users/{id}', 'list-one');
$r->get('/api/users', 'list-all');
$r->post('/webhook/stripe', 'webhook');
$cases = [
['/api/users/42', 'GET'],
['/api/users', 'GET'],
['/api/users/42', 'POST'], // no match: POST not registered for /{id}
['/webhook/stripe', 'post'], // case-insensitive method
['/nope', 'GET'], // no match: no route
];
foreach ($cases as [$path, $method]) {
$hit = $r->match($path, $method);
printf(
"%-22s %-6s -> %s\n",
$path,
$method,
$hit !== null
? sprintf('%s params=%s', $hit['route']->handler, json_encode($hit['params']))
: 'no match',
);
}
Run it:
php verify.php
Expected output:
/api/users/42 GET -> list-one params={"id":"42"}
/api/users GET -> list-all params=[]
/api/users/42 POST -> no match
/webhook/stripe post -> webhook params=[]
/nope GET -> no match
If you see the five lines above, the Router is correct. Delete
verify.php afterwards. Chapter 6 replaces it with a proper
PHPUnit case.
What just happened, in five sentences
- You wrote a
Routevalue object that carries one registered route in immutable form, with its regex precompiled. - You wrote a
Routersingleton that registers routes via four verb methods (each delegating to a privateadd()), recompiling the pattern on every registration. - The pattern compiler turned
{name}placeholders into named PCRE groups and captured the parameter names in declaration order. match()filtered routes by method, ran the regex, and on the first hit returned the Route together with the extracted parameters keyed by name.- A
verify.phpscript proved the matcher's contract with zero Scriptor involvement, just composer's PSR-4 autoloader.
The Router is now a stand-alone library that happens to live inside
a Scriptor plugin. Chapter 4 wires HTTP around it. Chapter 5 hooks
it into a real request via _ext.php. Chapter 6 turns verify.php
into a proper test suite.
To-do before chapter 4
-
src/Route.phpexists with the readonly value object as shown. -
src/Router.phpexists with the singleton accessor, four verb methods, the pattern compiler, andmatch(). -
composer installfrom inside the plugin directory writes avendor/with a working autoloader. - Running
php verify.phpfrom the plugin root prints the five expected output lines. - The plugin still loads in your local Scriptor (the
[scriptor-simple-router] plugin registered (no-op)log line from chapter 2 is still there on every page reload).
When all five boxes are ticked, open chapter 4 and add the Request / Response wrappers that turn matched routes into real HTTP responses.
Behind the scenes. The named-group regex trick ((?P<name>...))
is the same one Symfony Routing's compiler emits, simplified down
to the 80% case. preg_match performance on a list of regexes is
fine up to a few hundred routes; past that, a router with a single
combined regex (FastRoute-style) becomes worthwhile. The
Concepts → Plugin Manager chapter
covers the broader plugin-discovery flow that wraps everything in
this track.