Skip to content
academia.sh

Lesson 03 / 21

Routing

Route patterns, parameters, groups and named routes; the first matching route in registration order wins, and a route can be left unwired in the shadow of another route.

Published Updated

In this lesson

  • Reads a pattern parameter, a group prefix and URL generation from a named route in a router model
  • Measures that, because the first matching route wins, a broad pattern shadows a narrower pattern registered after it
  • Compares the shadowed route moving to ran when the registration order changes
  • Measures with a pattern constraint that the cause of the shadow is the breadth of the pattern and not the order
Contents

The previous lesson gave ready-made the action the request lands on. This lesson opens that step. To serve a request, the framework looks at the registered routes one by one, and the rule of that look consists of a single sentence. What happens when two patterns fit the same path depends on how that sentence is written.

The Route Pattern and the Parameter

A route brings four things together: an HTTP method, a path pattern, a part name and an action. The pattern carries the fixed parts of the path and the parameters written in braces. The pattern /posts/{id} fits the path /posts/7, and the action receives the id parameter as 7. The framework turns the pattern into a regular expression: each parameter becomes a group that matches one or more characters that do not contain a slash.

So that the same pattern need not be repeated in many routes, routes are grouped. A group carries a common prefix: the pattern /{id} inside the /posts group actually becomes /posts/{id}. In real frameworks a group can also add a list of middleware to each of its members; middleware itself is the subject of the fifth lesson, and here only the prefix is modeled. The third tool is the named route: the route is registered under a name, and the URL is generated from the pattern by that name. This way, when a pattern changes, there is no need to search for every place the address was written.

router.php gathers these three tools and the matching rule in a single class. This file is given in full, and the next two measurements load it with require_once.

<?php
// router.php — a small router that matches in registration order
declare(strict_types=1);
require_once 'ran.php';

final class Response {
    public function __construct(public int $status, public string $body = '') {}
}

final class Router {
    private array $routes = [];
    private array $names = [];
    private array $prefixes = [];
    public function group(string $prefix, Closure $register): void {
        $this->prefixes[] = $prefix;
        $register($this);
        array_pop($this->prefixes);
    }
    public function route(string $method, string $pattern, string $part, Closure $action, ?string $name = null): void {
        $pattern = implode('', $this->prefixes) . $pattern;
        $this->routes[] = [$method, $pattern, $part, $action];
        if ($name !== null) $this->names[$name] = $pattern;
    }
    public function url(string $name, array $params = []): string {
        return preg_replace_callback('#\{(\w+)(?::[^}]+)?\}#',
            fn(array $m) => (string) $params[$m[1]], $this->names[$name]);
    }
    public function handle(string $method, string $path): Response {
        Ledger::newRequest();
        foreach ($this->routes as [$m, $pattern, $part, $action]) {     // the first match wins
            $regex = '#^' . preg_replace_callback('#\{(\w+)(?::([^}]+))?\}#',
                fn(array $x) => '(?P<' . $x[1] . '>' . ($x[2] ?? '[^/]+') . ')', $pattern) . '$#';
            if ($m === $method && preg_match($regex, $path, $found)) {
                Ledger::onPath($part);
                Ledger::ran($part);
                return $action(array_filter($found, 'is_string', ARRAY_FILTER_USE_KEY));
            }
        }
        return new Response(404);
    }
}

The ran.php core is given again in every lesson with the same twenty-two lines. The router attaches to it with two calls: it reports the name of the matching route with both Ledger::onPath() and Ledger::ran().

<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}

FF9. The foreach in handle() tries the routes in the order they were registered and returns on the first match. This line is the rule of the whole lesson: the first match wins. The model does not judge which pattern is more specific by any ranking, and there is no rule such as “a fixed pattern comes before a parameterized pattern”. The order is the order in which the developer wrote the route() calls. Some real frameworks soften this rule with their own additional ordering; the model measured here does not soften it, and this is a deliberate simplification.

Groups and Named Routes

group() works with a list of prefixes: a prefix is added, the route() calls inside the group run, and the prefix is taken back. Because the prefix is added to the front of the pattern inside route(), the matcher knows nothing of the group; it looks only at the full pattern. Groups can be nested, and each level adds its own prefix. This design has a consequence: a group is only a writing convenience and adds no new rule to matching. When /{id} and /new are registered inside the /posts group, these two patterns behave exactly as /posts/{id} and /posts/new written independently of the group would.

url() does the reverse. It takes the registered pattern, puts the given value in place of each and returns the result. The route name is tied to the pattern, and the pattern is stored together with the prefix; so changing the group’s prefix changes the address generated by every piece of code that uses the name. That is all the named route does in this lesson: it ties address generation to a single place in the pattern. It carries no word about which route will fit a request.

The First Match Wins

routing.php builds a /posts group and puts two routes inside it: show with the pattern /{id} and form with the pattern /new. build() produces two registration orders. In the first order, show is registered first and form after. In the second, it is the reverse. The words after and before in the output say whether form was registered after or before show. Both routers are asked about the same path, the address /posts/new; the part the developer expects is form. The show route inside the group also carries the name posts.show, and the last line generates a URL from that name with the value 7 for id.

<?php
// routing.php — the registration order decides which route is wired
declare(strict_types=1);
require_once 'router.php';

function build(bool $newFirst): Router {
    $r = new Router();
    $r->group('/posts', function (Router $r) use ($newFirst) {
        $form = fn() => new Response(200, 'empty form');
        $show = fn(array $p) => new Response(200, "post {$p['id']}");
        if ($newFirst) $r->route('GET', '/new', 'form', $form);
        $r->route('GET', '/{id}', 'show', $show, name: 'posts.show');
        if (!$newFirst) $r->route('GET', '/new', 'form', $form);
    });
    return $r;
}

foreach (['after' => false, 'before' => true] as $label => $newFirst) {
    $r = build($newFirst);
    $response = $r->handle('GET', '/posts/new');
    printf("new %-6s %d %-10s form %s\n", $label, $response->status, $response->body, classify('form'));
}
echo $r->url('posts.show', ['id' => 7]), "\n";
new after  200 post new   form not wired
new before 200 empty form form ran
/posts/7

FF10. The same path, the same two routes, the only difference the registration order. When show was registered first, /posts/new fitted it: the parameter took the value new, the response was 200 and its body came out as post new. The form action was never called; Ledger did not put it on the request’s path, and when the decision order could not find it in the $ran, $deferred or $path lists either, it said not wired. When form was registered first, the same path fitted it and the empty form response came: form ran. The response is 200 in both cases and no error or warning appears; only the body and Ledger tell the two cases apart.

That is why the problem is insidious. The shadowed route is registered, and someone reading the route table sees it. The request never reaches it, and the framework does not report this; the post new response does not look like an error either, because new can be read as a valid id value. The developer realizes that a part did not run not from somewhere in the response but by naming the part they expected and querying it.

The generated URL is also part of the model: /posts/7 on the last line was derived from the pattern. Code that uses the name instead of writing the address by hand does not break when the pattern changes. But the named route only generates URLs; what decides which route fits a request is still the registration order.

This lesson’s outcome is not wired. This outcome, the last in the decision order, was named the most dangerous outcome in the first lesson: a skipped part shows through a status code, a deferred part shows in a worker or after the response, while a part that is not wired shows nowhere.

Reading the Route Table

The only reliable way to find a shadowed route is to write down the route’s name beforehand and ask the request about it. Someone who reads the route table by eye sees the form route registered and thinks the work is done. Ledger reads not the table but the request: was form on the path in this request, and was it called. The two questions are separate, and only the second is what the developer really wonders about.

This habit of reading has two practical consequences. The first is that adding a new route can shadow an existing route: a broad pattern added later covers all the narrow patterns registered after it. This effect, which the person adding it does not see, is caught by measuring the same request before and after. The second is that a developer who realizes a route did not run tends to look first inside the action; yet the action was never called and there is nothing in it to search for. The right first question is which route the request fell on.

The Bounding Measurement

Does the order cause the shadow? The previous measurement showed that the result changes when the order changes; but that does not prove that the order is the only factor. The bounding measurement does not change the order. show is again registered first, only its pattern is narrowed to {id:\d+}: the parameter matches digits only. The same two paths are asked: /posts/new and /posts/7.

<?php
// constraint.php — the breadth of the pattern causes the shadow, not the order
declare(strict_types=1);
require_once 'router.php';

$r = new Router();
$r->group('/posts', function (Router $r) {
    $r->route('GET', '/{id:\d+}', 'show', fn(array $p) => new Response(200, "post {$p['id']}"));
    $r->route('GET', '/new', 'form', fn() => new Response(200, 'empty form'));
});

foreach (['/posts/new' => 'form', '/posts/7' => 'show'] as $path => $part) {
    $response = $r->handle('GET', $path);
    printf("%-14s %d %-10s %s %s\n", $path, $response->status, $response->body, $part, classify($part));
}
/posts/new     200 empty form form ran
/posts/7       200 post 7     show ran

FF11. The order stayed the same, and this time form ran. Because the show pattern no longer matches the text new, the turn came to the form route; /posts/7 fell on show again. What causes the shadow is not the order but the broad pattern: because [^/]+ accepts any text, the word new fits it too. The order matters only when two patterns cover each other. If the pattern is narrowed, the order loses its importance.

What the two measurements say together is this: for a route you see is not wired, one of two ways fixes it, and both give the same result. Register the narrow pattern before the broad pattern, or narrow the broad pattern. The second is more robust, because the result does not change even if the order changes. The first depends on the developer’s attention and can break again when the next route is added.

There is one more limit: by default this model matches a single path segment that contains no slash; it supports neither wildcards nor optional parameters. The matchers of real frameworks support these and more, such as domain constraints; what is measured here is only the consequence of the first match wins rule.

Summary

  • A route consists of a method, a pattern, a part name and an action; a group adds a common prefix, and a named route generates a URL from the pattern.
  • In the model the first match wins and there is no specificity ranking; the order is the order in which the routes were registered.
  • When /posts/{id} was registered first, /posts/new fitted it, the response was 200 with post new, and form was not wired; when the order changed, form ran.
  • Narrowing the parameter with {id:\d+} brought the form route to ran without changing the order; the shadow is caused not by the order but by the breadth of the pattern.
  • This lesson’s outcome is not wired: a shadowed route raises no error, the response looks successful, and the route is found only by naming it and querying it.

Next Step

The matching route is now known: show or form ran. But a route only points to an action; in which class the action lives, who builds that class’s dependencies, and how a resource with seven actions spreads across routes have not yet appeared. The next lesson, Controllers, asks how the class the route points to is built and whether the action was really called.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close