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 static instance() 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. The Router::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 (a head() method might force handler: 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.
  • &$paramNames captures 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 runs preg_quote on 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

  1. You wrote a Route value object that carries one registered route in immutable form, with its regex precompiled.
  2. You wrote a Router singleton that registers routes via four verb methods (each delegating to a private add()), recompiling the pattern on every registration.
  3. The pattern compiler turned {name} placeholders into named PCRE groups and captured the parameter names in declaration order.
  4. match() filtered routes by method, ran the regex, and on the first hit returned the Route together with the extracted parameters keyed by name.
  5. A verify.php script 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.php exists with the readonly value object as shown.
  • src/Router.php exists with the singleton accessor, four verb methods, the pattern compiler, and match().
  • composer install from inside the plugin directory writes a vendor/ with a working autoloader.
  • Running php verify.php from 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.