Routing¶
Namespace Keel\Routing. Routes are declared with attributes on controller methods,
compiled into match tables and cached as a PHP file.
Declaration¶
use Keel\Routing\Attribute\{Get, Post, Put, Delete, Route, Prefix, Middleware, Status};
#[Prefix('/api/users')]
#[Middleware(AuthMiddleware::class)]
final class UserController
{
#[Get('/{id:\d+}', name: 'users.show')]
public function show(int $id): UserDto { /* ... */ }
#[Post('')]
#[Status(201)]
public function create(#[FromBody] CreateUser $command): UserDto { /* ... */ }
#[Route('/export', methods: ['GET', 'POST'], name: 'users.export')]
#[Middleware(RateLimit::class)]
public function export(): ResponseInterface { /* ... */ }
}
#[Get],#[Post],#[Put],#[Patch],#[Delete]are shortcuts for#[Route]with one method. The attributes are repeatable: one method can serve several paths.#[Prefix]on the class is prepended to every path in the class. An empty method path''or'/'yields exactly the prefix (/api, not/api/). A prefix on a parent class comes first: parent classes are route groups, see below.#[Middleware]on the class applies to every route of the class and runs before the method-level middleware.#[MiddlewareWith(Throttle::class, limit: 5, window: 60)]adds one middleware built with constructor arguments, for middleware without an attribute of its own (third-party or generic). Named arguments go to the parameters of that name, the rest are autowired; the instance is built once per route and kept, so it must hold no request state. Values travel through the route cache, so they must be exportable. The same pair form,[Throttle::class, ['limit' => 5]], works in the global middleware list ofHttpConfig.#[Redirect('/old', '/new', 308)]on the class declares a route that only redirects; see Redirects.#[Status]sets the status of the response thatResponderbuilds from the return value.- A handler is a public non-static method. Closures are not supported: they cannot be compiled into the cache.
Extension attributes¶
Two interfaces let other attributes travel with a route. RouteOption stores a value on the
route (Route::$options[key], read with Route::option('key')): declared on the class it applies
to every route, a method-level attribute with the same key overrides it; values go through the
route cache, so they must be exportable (scalars, arrays). MiddlewareProvider adds middleware
classes to the route; #[Middleware] itself is one. #[Auth] and #[RateLimit] implement both,
which is how they enforce themselves: the attribute records its settings and adds the guard that
reads them from the RouteMatch request attribute.
Path syntax¶
{name}matches one segment without/([^/]+).{name:regex}uses a custom pattern, e.g.{id:\d+},{year:\d{4}},{lang:(ru|en)}. Plain capturing groups inside are turned into non-capturing ones (\(and[(]stay literal); a named group such as(?<n>...)cannot be neutralised and is rejected withRoutingException.- The pattern is matched against the percent-encoded path (see Matching):
\d+and[a-z-]+work,\p{L}or any other non-ASCII constraint never matches an encoded%C3%B6. For non-ASCII values keep the default[^/]+and validate the decoded value in the handler. - A path starts with
/. The trailing slash is part of the path:/usersand/users/are different routes. [...]at the end of the path marks optional segments, nested if needed:/users[/{id:\d+}[/{tab}]]serves/users,/users/5and/users/5/posts. A method path that is entirely optional (#[Get('[/{path:.+}]')]) attaches to the class prefix. A short match leaves its placeholders out of the parameters, so the handler argument takes its default (?int $id = null,string $tab = 'profile'); a parameter without a default is a configuration error. Brackets are only allowed at the end:/a[/b]/cand/a[/b][/c]are rejected. A[inside a constraint ({slug:[a-z]+}) is a character class, not a marker.
Groups¶
A controller's parent classes act as route groups. #[Prefix], #[Middleware] and every
RouteOption attribute (#[Auth], #[RateLimit], #[CacheControl], ...) declared on a
parent apply to the subclass: prefixes are joined root-first, parent middleware runs first,
and an option declared again on the subclass or the method overrides the parent's.
#[Prefix('/api/v1')]
#[Middleware(ApiKeyMiddleware::class)]
#[Auth]
abstract class ApiController {}
#[Prefix('/users')] // routes start with /api/v1/users
final class UserController extends ApiController
{
#[Get('/{id}')]
public function show(int $id): UserDto { /* ... */ }
}
The abstract parent is not a controller itself (discovery skips classes that cannot be instantiated), so nothing is declared twice. Nest as deep as you like.
Redirects¶
#[Prefix('/api')]
#[Redirect('/v1/users/{id}', '/api/v2/users/{id}', 308, name: 'users.v1')] // /api/v1/users/{id}
#[Redirect('/docs', 'https://docs.example.com/', methods: ['GET', 'POST'])]
final class UserController { /* ... */ }
#[Redirect(from, to, status, methods, name)] is repeatable on a controller class and needs
no method: the route is served by Keel\Routing\Redirector, which answers with the status
(302 by default; 301, 303, 307 or 308) and a Location built from to. The class prefix and
class middleware apply to from as to any route; to is used as written, a path or an
absolute http(s) URL, and may repeat the placeholders of from, whose matched values are
substituted percent-encoded. Redirect routes appear in keel:routes and are left out of the
OpenAPI document.
From a handler, return Keel\Http\Redirect: Redirect::to('/login'),
Redirect::to('/done', 303) or Redirect::route('users.show', ['id' => 5]), which the
Responder turns into the response (the named form needs the Router, which the kernel
injects). Responder::redirect($location, $status) builds the same response directly.
Matching¶
Static paths are looked up in a hash; dynamic ones with one preg_match per chunk of 10 routes
(the patterns are joined with (?|...) and the number of the matched group identifies the route,
as in FastRoute). Matching runs against the raw request path, without decoding, so that
%2F cannot change the structure; placeholder values are decoded individually with
rawurldecode. A \d+ constraint is checked before decoding, so /users/%34%32 does not match.
A static path always wins over a dynamic one. Among the dynamic routes of a method the first
declared route that matches wins, so when two patterns overlap (/items/{id:\d+} and
/items/{slug} both match /items/42) declare the more specific one first. Declaration order is
the order of the attributed methods in a controller, of the controllers passed to
fromControllers(), or of the list given to fromRoutes(); the route cache keeps it.
Router::match($method, $path) returns a MatchResult with status Found (a RouteMatch
holding the route and the parameters), NotFound or MethodNotAllowed with the allowed
list. HEAD without a route of its own is served by the GET route, and allowed (the Allow
header of a 405) lists HEAD whenever it lists GET. An empty request path (PSR-7 allows it for
http://host) is routed as /.
Cache¶
Router::fromControllers(UserController::class)->write('var/cache/routes.php'); // at build time
$router = Router::fromCache('var/cache/routes.php'); // in production
The cache is a PHP array loaded with one require and kept in opcache. In development use
Router::fromControllers() on every request.
URL generation¶
$router->generate('users.show', ['id' => 5]); // /api/users/5
$router->generate('users.show', ['id' => 5, 'page' => 2]); // /api/users/5?page=2
Extra parameters become the query string, null values are skipped, values are encoded with
rawurlencode. With optional segments the longest variant whose placeholders are all given
(and not null) is generated: generate('users') is /users, generate('users', ['id' => 5])
is /users/5. Each value is checked against its placeholder constraint, either as given (with
/u, so {slug:[\p{L}-]+} accepts wörld) or in its encoded form (a/b satisfies [^/]+ as
a%2Fb, exactly as the matcher will see it). A missing parameter, a pattern violation and an
unknown name raise RoutingException.
Signed URLs¶
Keel\Auth\UrlSigner signs links that grant access by themselves: downloads, unsubscribe,
email confirmation. The path comes from the router, the signature from CookieSigner under
its own purpose, and #[Signed] checks it on the way in.
// config/signed_url.php
return static fn(Env $env): SignedUrlConfig => new SignedUrlConfig(
secret: $env->require('APP_SECRET'), // 32+ characters
ttl: 86_400, // default expiry in seconds; null = never
baseUrl: 'https://app.example.com', // for links that leave the app, as in emails
);
#[Get('/downloads/{id:\d+}', name: 'downloads.show')]
#[Signed] // 403 without a valid, unexpired signature
public function download(int $id): Response { /* ... */ }
$signer->route('downloads.show', ['id' => 5, 'format' => 'pdf'], expires: 3600);
// https://app.example.com/downloads/5?format=pdf&expires=1767225600&signature=Xb3...
$signer->sign('/reports/q3?inline=1', new \DateTimeImmutable('+1 day')); // any path
$signer->verify($url); // true / false
$signer->status($request->getUri()); // SignedUrlStatus::Valid, Missing, Invalid, Expired
- The signature covers the path and the whole query string exactly as sent,
expiresincluded: a parameter added, removed, reordered or changed, or a later expiry, gives 403Invalid URL signature; a past oneThis link has expired; noneThis URL must be signed. - Scheme, host and
baseUrlare not signed: the link is valid wherever the application receives the same path. Behind a proxy that mounts the application under a prefix, put the prefix inbaseUrl(https://example.com/app) and the links stay valid. expiresis seconds from now or aDateTimeInterface;signatureandexpiresare reserved query parameter names.purpose(defaultsigned-url) keeps these signatures apart from session cookies signed with the same secret.- A link is valid until it expires or the secret changes. To revoke single links, put something revocable in them (a token, a version) and check it in the handler.
- OpenAPI lists
expiresandsignatureas query parameters of#[Signed]operations, with a 403 response.
Configuration errors¶
Keel\Routing\Exception\RoutingException (a LogicException): a path without a leading /,
a placeholder name used twice, an invalid pattern or one with a capturing group that cannot be
neutralised, a route name declared twice, a static path or a dynamic pattern declared twice for
the same method (/u/{id} and /u/{slug} compile to the same regex), a static or abstract
handler, optional segments that are not at the end of the path, a #[Redirect] with a
non-3xx status or a target that is neither a path nor an http(s) URL.