Middleware
Middleware is a piece of code that runs before and/or after your route handler — perfect for authentication, logging, CORS, rate limiting, request mutation, response compression, and anything else that's cross-cutting.
Lift implements the PSR-15 middleware interface, which means:
- Any third-party PSR-15 middleware works out of the box.
- Middleware you write for Lift works in Slim, Mezzio, ReactPHP, etc.
Mental model: middlewares wrap the handler like onion layers. The request flows down to the handler, the response flows up through the same layers in reverse.
A middleware in 12 lines
use Lift\Http\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class RequestIdMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $req, RequestHandlerInterface $next): ResponseInterface
{
$id = $req->getHeaderLine('X-Request-Id') ?: bin2hex(random_bytes(8));
// ↓ pass control to the next layer
$response = $next->handle($req->withAttribute('request_id', $id));
// ↑ inspect/modify the response on the way back
return $response->withHeader('X-Request-Id', $id);
}
}
That's the entire contract. One method, four lines of "real" code (the rest is the type-safe use block).
Attaching middleware
Global — runs on every request
$app->use(CorsMiddleware::class); // class name (autowired through the container)
$app->use(new RateLimitMiddleware(60)); // pre-built instance
$app->use(RequestIdMiddleware::class);
You can pass a class name (Lift will resolve it via the container the first time it's needed) or an instance you built yourself. Both work; pre-built instances avoid reflection on hot paths.
Per-route
Chain ->middleware(...) on the route:
$app->get('/secret', $handler)
->middleware(AuthMiddleware::class);
$app->post('/users', [UserController::class, 'store'])
->middleware(AuthMiddleware::class, RateLimitMiddleware::class);
Per-group
Apply to a whole group at once:
$app->group('/admin', function ($g) {
$g->get('/users', [AdminController::class, 'users']);
$g->get('/settings', [AdminController::class, 'settings']);
})->middleware(AuthMiddleware::class, RequireAdminMiddleware::class);
Nested groups inherit the outer middleware and can add their own.
Execution order — the onion model
$app->use(A); // outermost
$app->use(B);
$app->group('/api', fn($g) => $g
->get('/x', $h)
->middleware(C)); // innermost
// Request lifecycle for GET /api/x:
// A → B → C → handler
// A ← B ← C ← response
Each middleware decides whether to delegate ($next->handle($req)) or short-circuit by returning a Response directly. A short-circuit means later middleware never runs — perfect for auth guards:
public function process($req, $next): ResponseInterface
{
if (! $this->validate($req->getHeaderLine('Authorization'))) {
return Response::json(['error' => 'Unauthorized'], 401);
// ↑ no $next->handle(…) call — pipeline stops here
}
return $next->handle($req);
}
Constructor injection
Middleware classes go through the container, which means they can have dependencies:
final class LogMiddleware implements MiddlewareInterface
{
public function __construct(
private readonly Psr\Log\LoggerInterface $log,
private readonly Clock $clock,
) {}
public function process($req, $next): ResponseInterface { /* ... */ }
}
$app->use(LogMiddleware::class); // Logger and Clock autowired
If you pass the class name (not an instance), Lift resolves it through the container exactly once and caches the result for subsequent requests in the same process.
Modifying request → passing data to the handler
The standard pattern: attach values to the request via PSR-7 attributes.
final class AuthMiddleware implements MiddlewareInterface
{
public function __construct(private readonly UserRepository $users, private readonly Jwt $jwt) {}
public function process($req, $next): ResponseInterface
{
$token = trim((string) preg_replace('/^Bearer\s+/i', '', $req->getHeaderLine('Authorization')));
try {
$claims = $this->jwt->decode($token);
} catch (\Throwable) {
return Response::json(['error' => 'Unauthorized'], 401);
}
$user = $this->users->find((int) $claims['sub']);
if ($user === null) {
return Response::json(['error' => 'User gone'], 401);
}
// ↓ attach for the handler
return $next->handle($req->withAttribute('user', $user));
}
}
// Read it in the handler:
$app->get('/me', fn(Request $req) => Response::json($req->getAttribute('user')))
->middleware(AuthMiddleware::class);
Modifying response
Same idea, on the way back out:
public function process($req, $next): ResponseInterface
{
$start = hrtime(true);
$response = $next->handle($req);
$ms = (hrtime(true) - $start) / 1e6;
return $response
->withHeader('Server-Timing', sprintf('total;dur=%.1f', $ms))
->withHeader('X-Powered-By', 'Lift');
}
Built-in middleware
Lift ships with a few production-grade middlewares ready to plug in:
| Middleware | Solves | Doc |
|---|---|---|
Lift\Middleware\CorsMiddleware |
CORS preflight + headers | Security |
Lift\Middleware\CsrfMiddleware |
CSRF (double-submit cookie) | Security |
Lift\Middleware\RateLimitMiddleware |
Token-bucket rate limit | Security |
Lift\Middleware\SecurityHeadersMiddleware |
HSTS, X-Frame-Options, etc. | Security |
Lift\Jwt\JwtMiddleware |
Bearer-token auth | JWT |
Lift\Debug\DebugToolbarMiddleware |
Dev toolbar | Debug |
Lift\Http\Session\SessionMiddleware |
Session bootstrap | Sessions |
Most have constructors that accept config. For example:
use Lift\Middleware\CorsMiddleware;
$app->use(new CorsMiddleware(
allowedOrigins: ['https://app.example.com'],
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
allowCredentials: true,
maxAge: 86400,
));
Examples
CORS (hand-rolled, when you want maximum control)
final class CorsMiddleware implements MiddlewareInterface
{
public function process($req, $next): ResponseInterface
{
if ($req->getMethod() === 'OPTIONS') {
return (new Response(204))
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
->withHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
->withHeader('Access-Control-Max-Age', '86400');
}
return $next->handle($req)
->withHeader('Access-Control-Allow-Origin', '*');
}
}
Request logging
final class LogMiddleware implements MiddlewareInterface
{
public function __construct(private readonly Psr\Log\LoggerInterface $log) {}
public function process($req, $next): ResponseInterface
{
$t0 = hrtime(true);
$response = $next->handle($req);
$ms = (hrtime(true) - $t0) / 1e6;
$this->log->info(sprintf(
'%s %s → %d (%.1f ms)',
$req->getMethod(),
$req->getUri()->getPath(),
$response->getStatusCode(),
$ms,
));
return $response;
}
}
Body-size guard
Reject requests with absurdly large bodies before they hit your handler:
final class MaxBodySizeMiddleware implements MiddlewareInterface
{
public function __construct(private readonly int $limitBytes) {}
public function process($req, $next): ResponseInterface
{
$len = (int) $req->getHeaderLine('Content-Length');
if ($len > 0 && $len > $this->limitBytes) {
return Response::json(['error' => 'Payload too large'], 413);
}
return $next->handle($req);
}
}
$app->use(new MaxBodySizeMiddleware(2 * 1024 * 1024)); // 2 MB
Compression (gzip)
final class GzipMiddleware implements MiddlewareInterface
{
public function process($req, $next): ResponseInterface
{
$res = $next->handle($req);
if (!str_contains($req->getHeaderLine('Accept-Encoding'), 'gzip')) {
return $res;
}
$body = (string) $res->getBody();
if (strlen($body) < 1024) {
return $res; // not worth it
}
return $res
->withHeader('Content-Encoding', 'gzip')
->withHeader('Vary', 'Accept-Encoding')
->withBody(\Lift\Http\Stream::fromString(gzencode($body, 6)));
}
}
Error → JSON
A middleware can catch exceptions thrown by deeper middleware/handlers:
final class JsonErrorMiddleware implements MiddlewareInterface
{
public function process($req, $next): ResponseInterface
{
try {
return $next->handle($req);
} catch (\Lift\Exception\HttpException $e) {
return Response::json(['error' => $e->getMessage()], $e->getStatusCode());
} catch (\Throwable $e) {
return Response::json(['error' => 'Server error'], 500);
}
}
}
In most cases you don't need this — Lift's built-in error handling already converts
HttpExceptionsubclasses +ValidationExceptionto appropriate responses. Use$app->onError(...)for app-level handling. See Error handling.
Anatomy of $next->handle($req)
The $next argument is a RequestHandlerInterface — a one-method object whose handle(ServerRequestInterface): ResponseInterface runs the rest of the pipeline starting from the next middleware. The framework builds this lazily so you never construct it yourself.
Calling $next->handle($req) more than once is technically allowed but almost always a bug (the handler would run twice). Don't.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Middleware never runs | Forgot to $app->use(...) or ->middleware(...) |
Register it. |
| Headers set in middleware are missing in response | You called withHeader(...) but didn't return the result |
return $response->withHeader(...);. |
| 500 with "no response returned" | Middleware forgot to return | Always return $next->handle($req) or your own Response. |
| Auth middleware runs after CORS preflight fails | CORS middleware is registered after auth | Register CORS first ($app->use(CorsMiddleware::class) before everything else). |
| Same middleware adds the same header twice | Registered both globally and per-route | Pick one. |
| Closure middleware | Lift requires MiddlewareInterface |
Wrap your closure into a class. (Lift deliberately doesn't allow closure middleware to keep the type contract tight.) |
Cheat sheet
// Define
final class MyMiddleware implements MiddlewareInterface
{
public function process($req, $next): ResponseInterface { /* ... */ }
}
// Attach
$app->use(MyMiddleware::class); // global
$app->use(new MyMiddleware($cfg)); // global, pre-built
$app->get($p, $h)->middleware(MyMiddleware::class); // per-route
$app->group($p, fn($g) => /* */)->middleware(MyMiddleware::class); // per-group
// Modify request / response
$req = $req->withAttribute('user', $user);
$res = $next->handle($req)->withHeader('X-Foo', 'bar');
// Short-circuit
return Response::json(['error' => 'denied'], 401);