Middleware
Middleware — це ділянка коду, що виконується до та/або після вашого обробника маршруту — ідеально для автентифікації, логування, CORS, обмеження частоти, зміни запиту, стиснення відповіді та всього іншого наскрізного.
Lift реалізує інтерфейс middleware із PSR-15, що означає:
- Будь-який сторонній PSR-15 middleware працює «з коробки».
- Middleware, написаний вами для Lift, працює у Slim, Mezzio, ReactPHP тощо.
Ментальна модель: middleware'и загортають обробник, як шари цибулини. Запит тече вниз до обробника, відповідь тече вгору через ті самі шари у зворотному порядку.
Middleware за 12 рядків
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));
// ↓ передаємо керування наступному шару
$response = $next->handle($req->withAttribute('request_id', $id));
// ↑ оглядаємо/змінюємо відповідь на зворотному шляху
return $response->withHeader('X-Request-Id', $id);
}
}
Це весь контракт. Один метод, чотири рядки «справжнього» коду (решта — типобезпечний блок use).
Під’єднання middleware
Глобально — виконується на кожному запиті
$app->use(CorsMiddleware::class); // ім’я класу (автозв’язується через контейнер)
$app->use(new RateLimitMiddleware(60)); // готовий екземпляр
$app->use(RequestIdMiddleware::class);
Можна передати ім’я класу (Lift розв’яже його через контейнер за першої потреби) або екземпляр, який ви побудували самі. Обидва варіанти працюють; готові екземпляри уникають рефлексії на гарячих шляхах.
Помаршрутно
Зчепіть ->middleware(...) на маршруті:
$app->get('/secret', $handler)
->middleware(AuthMiddleware::class);
$app->post('/users', [UserController::class, 'store'])
->middleware(AuthMiddleware::class, RateLimitMiddleware::class);
Погрупово
Застосуйте одразу до всієї групи:
$app->group('/admin', function ($g) {
$g->get('/users', [AdminController::class, 'users']);
$g->get('/settings', [AdminController::class, 'settings']);
})->middleware(AuthMiddleware::class, RequireAdminMiddleware::class);
Вкладені групи успадковують зовнішній middleware і можуть додати свій.
Порядок виконання — модель цибулини
$app->use(A); // найзовнішній
$app->use(B);
$app->group('/api', fn($g) => $g
->get('/x', $h)
->middleware(C)); // найвнутрішніший
// Життєвий цикл запиту для GET /api/x:
// A → B → C → обробник
// A ← B ← C ← відповідь
Кожен middleware вирішує, чи делегувати далі ($next->handle($req)), чи перервати ланцюжок, повернувши Response напряму. Переривання означає, що наступні middleware ніколи не виконуються — ідеально для стражів автентифікації:
public function process($req, $next): ResponseInterface
{
if (! $this->validate($req->getHeaderLine('Authorization'))) {
return Response::json(['error' => 'Unauthorized'], 401);
// ↑ немає виклику $next->handle(…) — конвеєр зупиняється тут
}
return $next->handle($req);
}
Впровадження через конструктор
Класи middleware проходять через контейнер, а отже можуть мати залежності:
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 і Clock автозв’язуються
Якщо ви передаєте ім’я класу (а не екземпляр), Lift розв’язує його через контейнер рівно один раз і кешує результат для наступних запитів у тому самому процесі.
Зміна запиту → передавання даних обробнику
Стандартний патерн: прикріпити значення до запиту через атрибути PSR-7.
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);
}
// ↓ прикріплюємо для обробника
return $next->handle($req->withAttribute('user', $user));
}
}
// Читаємо в обробнику:
$app->get('/me', fn(Request $req) => Response::json($req->getAttribute('user')))
->middleware(AuthMiddleware::class);
Зміна відповіді
Та сама ідея, на зворотному шляху назовні:
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');
}
Вбудовані middleware
Lift постачається з кількома middleware продакшен-рівня, готовими до під’єднання:
| Middleware | Розв’язує | Документація |
|---|---|---|
Lift\Middleware\CorsMiddleware |
CORS preflight + заголовки | Безпека |
Lift\Middleware\CsrfMiddleware |
CSRF (double-submit cookie) | Безпека |
Lift\Middleware\RateLimitMiddleware |
Обмеження частоти «token-bucket» | Безпека |
Lift\Middleware\SecurityHeadersMiddleware |
HSTS, X-Frame-Options тощо | Безпека |
Lift\Jwt\JwtMiddleware |
Автентифікація за Bearer-токеном | JWT |
Lift\Debug\DebugToolbarMiddleware |
Панель розробника | Налагодження |
Lift\Http\Session\SessionMiddleware |
Ініціалізація сесії | Сесії |
Більшість мають конструктори, що приймають конфігурацію. Наприклад:
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,
));
Приклади
CORS (написаний вручну, коли потрібен максимальний контроль)
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', '*');
}
}
Логування запитів
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;
}
}
Страж розміру тіла
Відхиляйте запити з абсурдно великими тілами до того, як вони дійдуть до вашого обробника:
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 МБ
Стиснення (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; // не варте того
}
return $res
->withHeader('Content-Encoding', 'gzip')
->withHeader('Vary', 'Accept-Encoding')
->withBody(\Lift\Http\Stream::fromString(gzencode($body, 6)));
}
}
Помилка → JSON
Middleware може перехоплювати винятки, викинуті глибшими middleware/обробниками:
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);
}
}
}
У більшості випадків це не потрібно — вбудована обробка помилок Lift уже перетворює підкласи
HttpException+ValidationExceptionна відповідні відповіді. Використовуйте$app->onError(...)для обробки на рівні застосунку. Див. Обробка помилок.
Анатомія $next->handle($req)
Аргумент $next — це RequestHandlerInterface, об’єкт з єдиним методом, чий handle(ServerRequestInterface): ResponseInterface виконує решту конвеєра, починаючи з наступного middleware. Фреймворк будує його ліниво, тому ви ніколи не конструюєте його самі.
Виклик $next->handle($req) більше одного разу технічно дозволений, але майже завжди є багом (обробник виконається двічі). Не робіть так.
Часті підводні камені
| Симптом | Причина | Виправлення |
|---|---|---|
| Middleware ніколи не виконується | Забули $app->use(...) або ->middleware(...) |
Зареєструйте його. |
| Заголовки, встановлені в middleware, відсутні у відповіді | Ви викликали withHeader(...), але не зробили return результату |
return $response->withHeader(...);. |
| 500 з «no response returned» | Middleware забув зробити return | Завжди return $next->handle($req) або ваш власний Response. |
| Auth-middleware виконується після провалу CORS preflight | CORS-middleware зареєстрований після auth | Реєструйте CORS першим ($app->use(CorsMiddleware::class) раніше за все інше). |
| Той самий middleware додає той самий заголовок двічі | Зареєстрований і глобально, і помаршрутно | Оберіть щось одне. |
| Middleware-замикання | Lift вимагає MiddlewareInterface |
Загорніть замикання у клас. (Lift навмисно не дозволяє middleware-замикання, щоб тримати типовий контракт суворим.) |
Шпаргалка
// Визначення
final class MyMiddleware implements MiddlewareInterface
{
public function process($req, $next): ResponseInterface { /* ... */ }
}
// Під’єднання
$app->use(MyMiddleware::class); // глобально
$app->use(new MyMiddleware($cfg)); // глобально, готовий екземпляр
$app->get($p, $h)->middleware(MyMiddleware::class); // помаршрутно
$app->group($p, fn($g) => /* */)->middleware(MyMiddleware::class); // погрупово
// Зміна запиту / відповіді
$req = $req->withAttribute('user', $user);
$res = $next->handle($req)->withHeader('X-Foo', 'bar');
// Переривання ланцюжка
return Response::json(['error' => 'denied'], 401);