Routing
Routing is how Fast decides which piece of your code runs for a given URL. Under the hood it is a regex-free segment trie, which is a fancy way of saying it matches paths with fast hash lookups instead of running a regular expression for every route. You do not have to think about that most of the time, but it is why routing stays quick even with a lot of routes.
Registering routes
The verb helpers cover the usual HTTP methods. Each takes a path and a handler:
get('/', fn (): string => 'Home');
post('/users', fn () => json(['created' => true]));
put('/users/{id}', UpdateUser::class);
patch('/users/{id}', 'UpdateUser@handle');
delete('/users/{id}', fn () => text('gone'));
any('/health', fn (): string => 'ok');
get() also answers HEAD requests automatically, so you do not need to register those separately. If you want to handle a specific set of methods, reach for route():
route(['PUT', 'PATCH'], '/users/{id}', UpdateUser::class);
Route parameters
Two dynamic forms are supported. The first captures exactly one path segment:
get('/users/{id}', fn () => json(['id' => request()->attribute('id')]));
The second is a trailing catch-all that grabs everything remaining. It must be the last segment, and it matches zero or more segments:
get('/files/{path*}', fn () => text(request()->attribute('path')));
That route matches /files/a/b/c with path set to "a/b/c", and it also matches a bare /files with path set to an empty string. You read both kinds of parameter the same way, through request()->attribute('name').
A parameter must occupy its whole segment. Fast rejects mixed or malformed segments such as {repo}.git, prefix-{id}, {}, and {*} during registration, instead of silently treating them as dead literal routes. Use a whole {repo} segment and handle any suffix in another segment or in application code. To match literal brace bytes, URL-encode them in the route template.
Typed route parameters
Instead of pulling parameters off the request as strings, a handler can declare them by name and type, and Fast will cast them for you:
get('/users/{id}', fn (int $id) => json(['id' => $id])); // $id is an int
get('/price/{amount}', fn (float $amount) => text((string) $amount));
get('/flag/{on}', fn (bool $on) => text($on ? 'yes' : 'no'));
The parameter name must match the route parameter. Casting is strict: a segment that does not fit its type (/users/abc reaching int $id) is treated as no such resource and answers 404, so the handler never runs. Booleans accept 1/0/true/false/yes/no/on/off (case-insensitive).
You can mix the request in wherever you like; declare a Request parameter and it is injected alongside the cast values, in whatever order you wrote them:
get('/posts/{id}', fn (Request $req, int $id) => text($req->path . ':' . $id));
The two classic shapes are unchanged: fn (Request $req) still receives just the request (no casting), and fn () still dispatches with no arguments. Declaring a parameter that matches no route parameter is a mistake and throws at registration. Fast does not autowire arbitrary handler arguments.
Because a bad cast is a
404decided before the pipeline runs, any per-route middleware on that route is skipped for the mismatching request.
How matching decides
When more than one route could match, Fast follows a clear precedence order: static beats dynamic beats catch-all, and it backtracks when needed. So a fixed segment and a {param} can happily sit at the same position, and the exact match always wins regardless of the order you registered them in.
get('/users/me', fn (): string => 'current user'); // static, wins for /users/me
get('/users/{id}', fn () => 'some user'); // dynamic, handles the rest
If both an exact endpoint and a catch-all are registered at the same spot, the exact endpoint wins for any method it handles. The catch-all only fills in for methods the exact route does not cover.
Per-parameter regex constraints like
{id:\d+}are intentionally not supported. A constrained segment throws at registration time. Validate the value inside your handler instead, which keeps routing simple and fast.
Handlers come in many shapes
A handler can be almost anything callable:
- a closure or plain function;
- an invokable class, like
CreateUser::class; - an array callable, like
[UserController::class, 'store']; - a string in
"Class@method"form.
Class handlers are resolved through the container, so their constructor dependencies get autowired. Whatever form you use, the return value follows the same rule everywhere in Fast: a Response is sent as-is, a string becomes HTML, and anything else becomes JSON.
Middleware and groups
Every route helper takes an optional trailing middleware: argument, a single middleware or an array of them, so you can guard one route without touching the global stack:
get('/admin', $handler, middleware: AuthMiddleware::class);
route(['PUT', 'PATCH'], '/users/{id}', UpdateUser::class, middleware: [Csrf::class]);
To share a prefix and/or middleware across several routes, wrap them in group(). Groups nest, concatenating prefixes and stacking middleware outer to inner:
group(['prefix' => '/api', 'middleware' => ApiKey::class], function (): void {
get('/ping', fn () => 'pong'); // => GET /api/ping
group(['prefix' => '/v1'], function (): void {
get('/users', fn () => 'users'); // => GET /api/v1/users
});
});
The details of what a middleware looks like and the exact execution order live in Middleware.
Named routes and url()
Give a route a name: and you can rebuild its path later instead of hardcoding it. This keeps links working when a path changes: you update the route, not every template that points at it:
get('/users/{id}', ShowUser::class, name: 'users.show');
url('users.show', ['id' => 42]); // => /users/42
url() fills dynamic segments from the params you pass (URL-encoding each), joins a catch-all across its sub-segments, and spills any leftover params into a query string:
get('/search/{term}', Search::class, name: 'search');
url('search', ['term' => 'php', 'page' => 2]); // => /search/php?page=2
A missing required parameter or an unknown name throws, so a broken link fails loudly at build time rather than silently producing a wrong URL.
Groups can contribute a dotted name prefix with 'as', which nests just like the path prefix:
group(['prefix' => '/admin', 'as' => 'admin'], function (): void {
get('/users', ListUsers::class, name: 'users'); // name => 'admin.users'
});
url('admin.users'); // => /admin/users
Route names must be unique: a duplicate throws at registration.
Once you can route requests, the next tool to reach for is Middleware.