---
title: Routing
source: 'https://academia.sh/en/courses/web-application-framework/routing'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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
<?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
<?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 `{parameter}` 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
<?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 `{id}` 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
<?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.
