italix/routing

Named routes and URL generation: declare a route once, link to it by name, and fail loudly at generation time instead of 404ing later

Maintainers

Package info

github.com/italix-net/routing

pkg:composer/italix/routing

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.0.0 2026-08-29 22:33 UTC

This package is not auto-updated.

Last update: 2026-08-30 22:39:22 UTC


README

PHP Version License

Named routes and URL generation. Declare a route once, link to it by name, and find out at generation time when the link is wrong instead of when a user reports a 404.

Zero dependencies. It parses FastRoute's pattern syntax; it does not use FastRoute, and it never sees a request.

php src/Libs/Italix/Routing/tests/PatternTest.php
php src/Libs/Italix/Routing/tests/UrlGeneratorTest.php

Declaring

routes.php returns fn(Registrar $r): void:

return function (Registrar $r) use ($route) {

    $r->group('/{lang:en|it}', function (Registrar $r) use ($route) {

        $r->named('password.reset', ['GET', 'POST'], '/reset-password/{token:[a-f0-9]{64}}',
            $route([PasswordResetAction::class, 'display']));

        $r->named('admin.users.edit', 'GET', '/admin/users/{id:new|\d+}/edit.html',
            $route([AdminUserAction::class, 'edit'], $admin));

        // Unnamed routes still dispatch; they just cannot be linked to.
        $r->route('GET', '/article/{id:\d+}', ArticleAction::class);
    });
};

group() is this library's own, not FastRoute's, because the table has to record the full pattern. A name whose stored pattern is missing its group prefix generates links without the language segment — which 404 for every user while the route works fine when typed by hand.

Linking

$url->to('admin.users.edit', ['lang' => 'it', 'id' => 41]);
// /it/admin/users/41/edit.html

Ambient defaults

lang does not belong in every call. Set it once:

$url = $this->url->with(['lang' => $lang]);

$url->to('admin.users.index');            // /it/admin/users/index.html
$url->to('admin.users.edit', ['id' => 41]);

A default a route does not use is simply unused. An explicit parameter nothing consumes is refused. That asymmetry is the whole ergonomic argument — short call sites, without letting a typo like userid silently turn into a query string.

Areas

An application often serves one set of screens under two prefixes — say /admin/ for staff and /portal/ for customers — guarded by different middleware. in() resolves a name inside a group:

$url = $this->url->with(['lang' => $lang])->in($area);   // 'admin' or 'portal'

$url->to('orders.index');          // admin.orders.index  or  portal.orders.index
$url->to('orders.edit', ['id' => 7]);

There is no fallback to the bare name. in('portal')->to('settings.index') throws when the portal area has no such screen — the check a hardcoded /admin/ string could never perform.

In a template

<?php use Italix\Encode\Html as H; ?>

<a href="<?= H::url($url->to('users.edit', ['id' => $id])) ?>">…</a>

H::url() still encodes. The generator produces a correct path; Encode decides it is safe to put in an attribute. Two jobs, two libraries.

Failing early

Every message names the route. A parameter fault also names the parameter, the value and the pattern it failed:

Italix\Routing\UrlException: Route "admin.users.edit" parameter "id" — "abc" does not match (new|\d+)

An unknown name suggests the nearest ones:

No route is named "admin.user.edit". Did you mean: admin.users.edit, admin.users.index?

try_to() returns null instead of throwing — for the genuinely optional case, a breadcrumb that may have no target. Everywhere else, let the exception through: swallowing it turns a broken link into a silently missing one.

Route caching

FastRoute does not replay the definition closure when its compiled dispatcher is cached. A name table filled as a side effect of dispatching would therefore be empty in production and full in development — the worst possible failure schedule.

So the definitions are replayed twice, from one declaration:

$route_definitions = require __DIR__ . '/routes.php';

// Dispatcher pass — a plain callable is the seam
'routes' => static function (RouteCollector $r) use ($route_definitions): void {
    $route_definitions(Registrar::into(
        static function ($method, string $pattern, $handler) use ($r): void {
            $r->addRoute($method, $pattern, $handler);
        }
    ));
},

// Table pass — no sink, so no handler is resolved
RouteTable::class => static fn(): RouteTable => RouteTable::record($route_definitions),

Nested braces

{token:[a-f0-9]{64}} contains a {64} quantifier inside the placeholder. A parser that stops at the first } reads the regex as [a-f0-9]{64 — not a syntax error, just a different pattern — and every generated magic link comes out subtly wrong. Password-reset and e-mail-confirmation links are exactly this shape, so the brace counting is tested directly.

From the CLI

ix routes                                   # every named route
ix routes admin.users                       # filtered by prefix
ix routes --params=lang=it,id=41            # also render each URL

Deliberately not

  • Optional segments ([...]) are refused. Generating a URL from one means deciding which parts to include, and a wrong guess produces a plausible URL that routes somewhere else. Declare the two forms as two named routes.
  • No automatic naming from the controller class. A name is an interface; deriving it from a class name means renaming the class breaks every template.
  • No route model binding, no reverse matching, no dispatching. This library turns a name into a string. The router routes.