Named routes and URL generation
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:
- Retrieve the route by name.
- For each
{param}placeholder in the URI pattern, substitute the corresponding value from the parameters array. - Any extra parameters not matched to a placeholder are appended as query string.
- 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
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;
}
}