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 404 decided 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.