0

Named routes and URL generation

Intermediate5 min read·fw-03-004

Concept

Named routes and URL generation allow you to reference routes by name rather than hardcoded URI strings. When a URI changes, only the route definition changes — all route('name', $params) calls update automatically.

Storing named routes: The RouteCollection maintains a separate name → Route index. When a route is given a name (via ->name('users.show')), it's added to this index.

URL generation: The UrlGenerator takes a route name + parameters, finds the route, then fills in the {param} placeholders with the provided values.

Algorithm:

  1. Retrieve the route by name.
  2. For each {param} placeholder in the URI pattern, substitute the corresponding value from the parameters array.
  3. Any extra parameters not matched to a placeholder are appended as query string.
  4. Prepend the application base URL.

Type-safe generation: If a required parameter is missing, throw an exception. If a parameter doesn't satisfy the route's constraint, throw (optional strictness).

Reverse routing is the term for this process — going from name + params → URL, the reverse of route matching (URL → route + params).

Signed URLs: Named routes can generate signed URLs with an expiry — a hash of the URL + secret appended as a query parameter. The route verifies the signature. Used for email verification links, password reset links.

Code Example

php
<?php
namespace Framework\Routing;

class UrlGenerator
{
    public function __construct(
        private readonly RouteCollection $routes,
        private readonly string $baseUrl,
    ) {}

    public function route(string $name, array $params = [], bool $absolute = true): string
    {
        $route = $this->routes->getByName($name);
        if ($route === null) {
            throw new \InvalidArgumentException("Route [{$name}] not defined.");
        }

        $uri      = $route->uri;
        $extras   = $params;

        // Replace {param} placeholders
        $uri = preg_replace_callback('/\{(\w+)\??\}/', function($match) use (&$extras, $name) {
            $paramName = $match[1];
            $optional  = str_ends_with($match[0], '?}');

            if (isset($extras[$paramName])) {
                $value = $extras[$paramName];
                unset($extras[$paramName]);
                return $value;
            }

            if ($optional) return '';

            throw new \InvalidArgumentException(
                "Missing required parameter [{$paramName}] for route [{$name}]."
            );
        }, $uri);

        // Extra params → query string
        $url = $absolute ? $this->baseUrl . $uri : $uri;
        if (!empty($extras)) {
            $url .= '?' . http_build_query($extras);
        }

        return $url;
    }

    public function signedRoute(string $name, array $params = [], ?\DateTimeInterface $expiry = null): string
    {
        if ($expiry !== null) {
            $params['expires'] = $expiry->getTimestamp();
        }

        $url       = $this->route($name, $params);
        $signature = hash_hmac('sha256', $url, config('app.key'));
        return $url . (str_contains($url, '?') ? '&' : '?') . 'signature=' . $signature;
    }
}

// Usage
// Route: GET /users/{id}/posts/{slug}  name: 'users.posts.show'
// route('users.posts.show', ['id' => 1, 'slug' => 'hello-world'])
// → http://app.com/users/1/posts/hello-world

// route('users.show', ['id' => 42, 'page' => 2])
// → http://app.com/users/42?page=2  (extra param as query string)

// In Router class — fluent name setting
class Router
{
    private ?Route $lastRoute = null;

    public function get(string $uri, mixed $action): Route
    {
        $route = new Route('GET', $uri, $action);
        $this->collection->add($route);
        $this->lastRoute = $route;
        return $route;
    }

    public function name(string $name): static
    {
        $this->lastRoute?->name($name);
        return $this;
    }
}