Attribute routing
Declarative routing using PHP 8 attributes — the same routes, but registered next to the code that handles them.
Mental model: instead of listing routes in a central file, you attach
#[Get('/users')]directly to the controller method. At boot, Lift scans the classes you point it at and registers everything in one pass.
Quickest possible example
use Lift\Attribute\Get;
use Lift\Attribute\Post;
use Lift\Http\Request;
use Lift\Http\Response;
final class UserController
{
public function __construct(private readonly UserRepository $repo) {}
#[Get('/users')]
public function index(): array
{
return $this->repo->all();
}
#[Get('/users/{id:\d+}')]
public function show(Request $req): Response
{
return Response::json($this->repo->find((int) $req->param('id')));
}
#[Post('/users')]
public function store(Request $req): Response
{
$data = $req->validate(['name' => 'required|string']);
return Response::json($this->repo->create($data), 201);
}
}
// In public/index.php
$app->loadControllers(UserController::class);
$app->run();
That's it. No $app->get(...), no central routes file.
The attributes
| Attribute | Target | Repeatable | Purpose |
|---|---|---|---|
#[Get] |
method, function | ✓ | GET route |
#[Post] |
method, function | ✓ | POST route |
#[Put] |
method, function | ✓ | PUT route |
#[Patch] |
method, function | ✓ | PATCH route |
#[Delete] |
method, function | ✓ | DELETE route |
#[Route] |
method, function | ✓ | Any verb (#[Route('OPTIONS', '/x')]) |
#[Group] |
class | once | URL prefix for every method in the class |
#[Middleware] |
class, method | ✓ | Attach middleware to a class or method |
All #[Get/Post/...] take the same arguments as their imperative counterparts:
#[Get('/users/{id:\d+}', name: 'users.show')]
public function show(Request $req): Response { … }
The name: argument plugs into $app->url('users.show', ['id' => 42]) exactly like ->name(...).
Class-level URL prefix — #[Group]
#[Group('/api/v1/users')]
final class UserController
{
#[Get('/')] // → GET /api/v1/users/
public function index() { … }
#[Get('/{id:\d+}')] // → GET /api/v1/users/{id}
public function show(Request $req) { … }
#[Post('/')] // → POST /api/v1/users/
public function store(Request $req) { … }
}
A class may carry only one #[Group]. For nesting, use multiple controllers (UserController, AdminUserController).
Middleware
Either at the class level (applies to every route in the controller) or per method (applies to that route only):
use Lift\Attribute\Middleware;
#[Group('/admin')]
#[Middleware(AuthMiddleware::class)]
#[Middleware(RequireAdminMiddleware::class)]
final class AdminController
{
#[Get('/dashboard')]
public function dashboard() { … }
#[Post('/users/{id:\d+}/ban')]
#[Middleware(RateLimitMiddleware::class)] // adds on top of class-level ones
public function ban(Request $req) { … }
}
Middleware classes are resolved through the container, so they can have constructor dependencies.
You can also pass an array:
#[Middleware([AuthMiddleware::class, LogMiddleware::class])]
final class X { … }
Several routes on one method
Both #[Route] and the verb-specific attributes are repeatable — apply more than once to map several URLs (or verbs) onto the same method:
#[Get('/users/{id:\d+}')]
#[Get('/users/by-uuid/{uuid:[a-z0-9-]+}')]
public function show(Request $req): Response
{
// Branch on which param exists
return ...;
}
#[Route('GET', '/widgets')]
#[Route('HEAD', '/widgets')]
public function index(): array { … }
Loading controllers
// One class:
$app->loadControllers(UserController::class);
// Many — chain or one call:
$app->loadControllers(
UserController::class,
OrderController::class,
AdminController::class,
);
Tip: keep a list of controllers in a config file and splat it:
$controllers = require __DIR__ . '/../config/controllers.php';
$app->loadControllers(...$controllers);
Interplay with imperative routes
Attribute and imperative routes coexist freely:
$app->loadControllers(UserController::class);
// Add a quick health check imperatively
$app->get('/health', fn() => ['ok' => true]);
Order doesn't matter; the router resolves the right one per request.
OPcache & save_comments
PHP attributes are stored in class doc-comments at the bytecode level. OPcache must keep them. In php.ini:
opcache.save_comments=1
This is the default — but some hardened production images set 0 for "smaller bytecode". Without it, the loader sees no attributes and silently registers zero routes.
Production cache
Attribute scanning uses reflection, which costs a few ms per controller. For dozens of controllers, combine with the route cache:
$cache = __DIR__ . '/../storage/routes.cache.php';
$router = $app->container()->get(\Lift\Routing\Router::class);
if (!$router->loadCache($cache)) {
$app->loadControllers(...$controllers);
$router->writeCache($cache);
}
The cache stores the resolved route table; subsequent requests skip both the controller scan and the route registration entirely.
When not to use attributes
- One-off scripts or 3-route APIs —
$app->get(...)is plainer. - When the same handler is called from multiple frameworks — keep routes external.
- When you need conditional registration (
if ($env === 'dev') ...) — only possible imperatively.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
$app->loadControllers(X::class) registers zero routes |
opcache.save_comments=0, or class has no #[Get]/... attributes |
Fix php.ini; ensure attributes exist. |
| Middleware not applied | Forgot to use Lift\Attribute\Middleware — PHP imported the wrong Middleware class |
Use full FQN \Lift\Attribute\Middleware or import explicitly. |
| Two routes registered for one method | You used both #[Get] and #[Route('GET', …)] for the same URL |
Pick one. |
Method registered as a route is static |
Static methods are skipped by the loader | Make it instance method, or call from a non-static dispatcher. |
Cannot resolve parameter $foo at boot |
Controller's constructor needs a class the container can't find | $app->bind(...) the dependency first. |
Cheat sheet
use Lift\Attribute\{Get, Post, Put, Patch, Delete, Route, Group, Middleware};
#[Group('/api/v1')]
#[Middleware(AuthMiddleware::class)]
final class WidgetController
{
#[Get('/widgets', name: 'widgets.index')]
public function index() { … }
#[Get('/widgets/{id:\d+}', name: 'widgets.show')]
public function show(Request $req) { … }
#[Post('/widgets')]
#[Middleware(RateLimitMiddleware::class)]
public function store(Request $req) { … }
#[Delete('/widgets/{id:\d+}')]
public function destroy(Request $req) { … }
}
$app->loadControllers(WidgetController::class);