Routing
The router maps an incoming HTTP request (method + path) to a handler (function/method to run). This page covers everything the router can do — verbs, parameters, named routes, groups, middleware, attribute routing, and the production cache.
Mental model: you tell the router "when a GET hits
/users/42, call this function". The router translates that to a fast lookup table at boot, then matches each request against it.
The five basic verbs
$app->get ('/path', $handler);
$app->post ('/path', $handler);
$app->put ('/path', $handler);
$app->patch ('/path', $handler);
$app->delete('/path', $handler);
Less common ones:
$app->any('/path', $handler); // GET POST PUT PATCH DELETE OPTIONS HEAD
$app->map(['GET', 'HEAD'], '/path', $handler); // a specific set
Every $app->get(...) returns a Route object that you can fluent-chain on (->name(), ->middleware()). More on that below.
Handler types
A handler is anything callable. Lift accepts five flavours and treats them all the same:
// 1. Closure
$app->get('/ping', fn() => 'pong');
// 2. Closure with type-hinted dependencies (autowired by the container)
$app->get('/orders', function (Request $req, OrderService $svc) {
return $svc->all();
});
// 3. [Class::class, 'method'] — class resolved through the DI container
$app->get('/users', [UserController::class, 'index']);
// 4. Invokable class (has __invoke)
$app->get('/healthcheck', HealthcheckAction::class);
// 5. Plain function name as a string
$app->get('/old-school', 'my_handler_function');
For 2 and 3, Lift looks at the parameter types and pulls instances from the container automatically. You never have to "register" your controllers; they're autowired.
What the handler can return
| Return value | What you get back |
|---|---|
Response object |
Passed through unchanged |
array or object |
Response::json(...) |
string |
Response::html(...) |
null |
Response::noContent() (204) |
| anything else (scalar) | Response::text((string) $v) |
So fn() => ['ok' => true] and fn() => Response::json(['ok' => true]) are equivalent. Use whichever reads better.
Route parameters
Anything inside {...} is captured and made available via $req->param(...):
$app->get('/users/{id}', function (Request $req) {
$id = $req->param('id'); // always a string
return ['id' => $id];
});
Get all params at once:
$all = $req->params(); // ['id' => '42']
Regex constraints
By default a {param} matches [^/]+ — any character except /. To require a specific pattern, append :regex:
$app->get('/posts/{id:\d+}', $handler); // digits only
$app->get('/files/{name:[a-z0-9-]+}', $handler); // slug
$app->get('/cards/{code:[A-Z]{3}-\d{4}}', $handler); // "ABC-1234"
A path that doesn't match the regex falls through to the next route (or 404 if nothing matches) — your handler is never called with a bad value.
WRONG:
(?P<name>...)style PCRE named groups — Lift uses its own placeholder syntax, not raw PCRE. RIGHT:{name}or{name:pattern}.
Optional segments
Lift does not support optional path segments inside one route (/users[/{id}] style). Register two routes instead:
$app->get('/users', fn() => 'list');
$app->get('/users/{id}', fn($req) => 'show ' . $req->param('id'));
Named routes & URL generation
Give a route a name with ->name(...), then build URLs by name with $app->url(...):
$app->get('/users/{id}', $h)->name('users.show');
$app->get('/articles/{slug}', $h)->name('articles.show');
$app->url('users.show', ['id' => 42]); // /users/42
$app->url('articles.show', ['slug' => 'hello']); // /articles/hello
Naming convention is up to you — users.show, users:show, users-show all work. The convention used across the docs and the generators is resource.action (users.index, users.show, users.store, users.update, users.destroy).
If the name doesn't exist, $app->url(...) throws RuntimeException.
Route groups
A group shares a path prefix (and optionally middleware) across many routes.
$app->group('/api/v1', function ($group) {
$group->get ('/users', [UserController::class, 'index']);
$group->get ('/users/{id:\d+}', [UserController::class, 'show']);
$group->post ('/users', [UserController::class, 'store']);
$group->put ('/users/{id:\d+}', [UserController::class, 'update']);
$group->delete('/users/{id:\d+}', [UserController::class, 'destroy']);
});
Nested groups
$app->group('/api', function ($api) {
$api->group('/v1', function ($v1) {
$v1->get('/ping', fn() => ['pong' => 'v1']);
});
$api->group('/v2', function ($v2) {
$v2->get('/ping', fn() => ['pong' => 'v2']);
});
});
Group middleware
Middleware applied to a group runs for every route inside it (and inherits into nested groups):
$app->group('/admin', function ($g) {
$g->get('/users', [AdminController::class, 'users']);
$g->get('/settings', [AdminController::class, 'settings']);
})->middleware(AuthMiddleware::class, RequireAdminMiddleware::class);
Order matters. Middleware listed first runs outermost — i.e. it sees the request before later middleware, and sees the response after later middleware.
Per-route middleware
Attach middleware to a single route by chaining ->middleware(...):
$app->post('/users', [UserController::class, 'store'])
->middleware(AuthMiddleware::class, RateLimitMiddleware::class);
Order of execution per request:
Global middleware (in order of $app->use())
→ Group middleware (outer to inner)
→ Route middleware (in declaration order)
→ Handler
← Route middleware
← Group middleware
← Global middleware
Each layer can short-circuit by returning a Response instead of calling $handler->handle($request).
404 and 405
| Situation | Exception thrown | Default response |
|---|---|---|
| URL doesn't match any route | Lift\Exception\NotFoundException |
404 JSON |
| URL matches a route but with the wrong method | Lift\Exception\MethodNotAllowedException |
405 JSON |
You can customise the response with $app->onError(...):
use Lift\Exception\NotFoundException;
use Lift\Exception\MethodNotAllowedException;
$app->onError(function (\Throwable $e, Request $req) {
if ($e instanceof NotFoundException) {
return Response::html('<h1>404 — page not found</h1>', 404);
}
if ($e instanceof MethodNotAllowedException) {
return Response::json(['error' => 'method not allowed'], 405);
}
return Response::json(['error' => 'server error'], 500);
});
Or hook a specific exception type:
$app->onException(NotFoundException::class, fn() => Response::html('Not here.', 404));
Full exception list → Error handling.
Attribute routing
Instead of declaring routes imperatively, you can attach #[Get], #[Post], etc. attributes to controller methods. The router will scan the classes you ask it to load.
use Lift\Attribute\Get;
use Lift\Attribute\Post;
use Lift\Attribute\Middleware;
use Lift\Attribute\Group;
#[Group('/api/v1/users')]
#[Middleware(AuthMiddleware::class)]
final class UserController
{
public function __construct(private readonly UserRepository $users) {}
#[Get('/')]
public function index(): array
{
return $this->users->all();
}
#[Get('/{id:\d+}')]
public function show(Request $req): Response { /* ... */ }
#[Post('/')]
#[Middleware(RateLimitMiddleware::class)]
public function store(Request $req): Response { /* ... */ }
}
// in public/index.php
$app->loadControllers(UserController::class, OrderController::class, ...);
Full reference: Attribute routing.
Production: route caching
Once you have lots of routes (50+), the registration step itself is non-trivial. The router can compile your routes to a flat PHP file that OPcache will load instantly:
$cache = __DIR__ . '/../storage/routes.cache.php';
$router = $app->router(); // shortcut for $app->container()->get(Router::class)
if (!$router->loadCache($cache)) {
// First request after deploy — register normally and write the cache
require __DIR__ . '/../routes/web.php';
$router->writeCache($cache);
}
⚠️ Closure handlers are silently skipped when writing the cache. Use
[Class::class, 'method']or invokable classes for any route you want cacheable. Closures still work, they just defeat caching.
Clear the cache on deploy (rm storage/routes.cache.php) and the first request will rebuild it.
App conveniences
$app exposes two helpers that are useful when you need the router or need to emit a response manually:
// Access the Router directly (useful for route cache, URL generation outside a request)
$app->router()->writeCache(storage_path('routes.cache.php'));
$app->router()->url('users.show', ['id' => 42]);
// Dispatch + emit — the normal case
$app->run();
// Dispatch without emitting (testing, CLI harnesses)
$response = $app->handle($request);
// … inspect $response …
$app->send($response); // emit when ready
Performance notes
- Static routes are O(1). A route with no
{param}lives in a hash map keyed by path + method. - Dynamic routes are O(n). A linear scan with PCRE per request. Even 100 dynamic routes resolve in a few microseconds.
- Reflection is cached. The router reflects each handler once per process and reuses the metadata across requests (huge speedup under OPcache + persistent SAPIs).
- Named-route map is lazy. Built once on the first
url()call, never rebuilt unless a new route is added.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Routes register but return 404 | Web server doesn't rewrite to index.php |
Re-check Nginx/Apache config in Installation. |
{id} arrives as '42' not 42 |
Route params are always strings | Cast: (int) $req->param('id'). |
| Middleware doesn't run | Forgot $app->use(...) or chained ->middleware(...) after ->name(...) (works, but easy to forget) |
Add it. Order independent of name(). |
Cannot resolve parameter $foo of type [App\X] |
Container can't find a binding and the class isn't autowirable | Either $app->bind(X::class, ...) or make the class concrete with autowirable constructor. |
$app->url('foo') throws |
Route foo was never registered with ->name('foo') |
Register it, or fix the typo. |
Cheat sheet
// Verbs
$app->get|post|put|patch|delete($path, $handler);
$app->any($path, $handler);
$app->map(['GET','POST'], $path, $handler);
// Parameters
'/users/{id}' // unrestricted
'/users/{id:\d+}' // regex-constrained
// Naming + URL gen
$app->get($p, $h)->name('foo');
$app->url('foo', ['id'=>1]);
// Groups
$app->group('/api', fn($g) => /* ... */)->middleware(M1::class);
// Per-route middleware
$app->get($p, $h)->middleware(M1::class, M2::class);
// Attribute routing
$app->loadControllers(UserController::class);
// Production cache
$router->loadCache($file) || ($router->writeCache($file));