Middleware
Middleware — это код, который выполняется до и после контроллера:
аутентификация, проверка прав, логирование, тайминги. В Winter middleware — это
класс, который одновременно является PHP-атрибутом, поэтому применяется
декларативно: #[AuthMiddleware] над контроллером или методом.
Что такое middleware и зачем
Middleware (промежуточный слой) — код, который оборачивает обработку запроса: выполняется до контроллера и после него.
Проблема. Многие задачи не относятся к бизнес-логике конкретного эндпоинта, но нужны сразу многим: проверить токен, убедиться в правах, залогировать запрос, замерить время. Если писать это в каждом контроллере — получаются копипаст и дублирование сквозной логики.
Решение — вынести обёртку в отдельный переиспользуемый класс и навешивать его
декларативно там, где нужно. В Winter такой класс одновременно является
PHP-атрибутом, поэтому подключение выглядит как одна строка над контроллером:
#[AuthMiddleware].
Объявление middleware
Заготовку создаёт генератор — суффикс Middleware он добавляет сам:
php call make -m .Auth # → main/AuthMiddleware.phpПолное объявление выглядит так. Здесь важна каждая строка — разберём их ниже:
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class AuthMiddleware extends Middleware
{
#[Autowired] private TokenService $tokens; // зависимости — как в контроллере
public function __construct(private string $role = 'user') {}
public function before(HttpRequest $request, HttpResponse $response): void
{
// проверка до контроллера
}
public function after(mixed $result): mixed
{
return $result; // преобразование ответа
}
}Из чего это собрано:
| Элемент | Обязателен | Что даёт |
|---|---|---|
extends Middleware |
да | Стереотип, по которому коллектор узнаёт middleware |
#[\Attribute(...)] |
да | Без него PHP не даст повесить класс атрибутом |
TARGET_CLASS | TARGET_METHOD |
да | Куда разрешено вешать — на контроллер, на метод или на оба |
| Конструктор | нет | Принимает аргументы, переданные атрибуту |
#[Autowired]-свойства |
нет | Экземпляр строит контейнер, инъекция работает |
before() / after() |
нет | Переопределяйте только нужный — второй остаётся no-op’ом |
Забытая строка `#[\Attribute]`
Самая частая ошибка при написании middleware вручную: класс наследует стереотип, но не объявлен атрибутом. PHP тогда откажется применять его над контроллером — ошибка возникнет при разборе атрибутов, а не при вызове. Генератор эту строку ставит сам.
Методы
Стереотип даёт два хука, и оба уже реализованы пустыми — переопределяйте тот, который нужен.
before() — до контроллера
public function before(HttpRequest $request, HttpResponse $response): voidВыполняется перед вызовом обработчика. Сюда идут проверки токена, прав и лимитов. Возвращаемого значения нет: middleware либо пропускает запрос дальше, либо прерывает его исключением.
Прервать запрос — бросить MiddlewareException (по умолчанию 401) или любое
ResponseException. Контроллер не вызовется, а исключение превратится в
HTTP-ответ:
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
public function before(HttpRequest $request, HttpResponse $response): void
{
$token = Header::getBearerToken();
if ($token === null) {
throw new MiddlewareException('Token missing'); // 401
}
if (!$this->tokens->isValid($token)) {
MiddlewareException::throw('Forbidden', HttpCode::FORBIDDEN); // 403
}
}К ответу об ошибке можно добавить заголовок:
throw new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)
->withHeader('Retry-After', '60');Прерывать — исключением, не `$response->end()`
У $response есть метод end(), но вызывать его из before() не следует: запрос
на этом не останавливается, управление всё равно уйдёт в контроллер, и ответ
попытается уйти клиенту дважды. Единственный способ прервать обработку — бросить
исключение. Записать заголовок через $response->header() при этом можно свободно
— он попадёт в итоговый ответ.
after() — после контроллера
public function after(mixed $result): mixedПолучает то, что вернул обработчик, до сериализации в HTTP-ответ — то есть
ResponseEntity, массив или строку, ровно как их вернул метод. Что вернёте вы, то
и пойдёт дальше по цепочке.
public function after(mixed $result): mixed
{
// единый конверт для всех ответов контроллера
if ($result instanceof ResponseEntity) {
return $result->body([
'data' => $result->getBody(),
'meta' => ['duration' => $this->elapsed()],
]);
}
return $result;
}`after()` — это не `finally`
Если обработчик бросил исключение, after() не выполнится: управление сразу
уходит в обработку ошибок. То же и при прерывании в before() — у middleware,
отработавших до него, after() не вызовется. Поэтому освобождение ресурсов,
закрытие транзакции или финализацию метрики на after() вешать нельзя — для
этого есть finally внутри самого обработчика.
Настройки
Всё, чем управляется подключение middleware, задаётся в двух местах: флагами в
#[\Attribute(...)] самого класса и тем, как атрибут применён.
Куда вешается
На контроллер целиком, на отдельный метод или и туда, и туда — тогда middleware складываются:
#[AuthMiddleware] // на весь контроллер
#[RequestMapping('admin')]
class AdminController extends Controller
{
#[GetMapping('info')]
public function info(): ResponseEntity { /* только Auth */ }
#[RateLimitMiddleware] // добавляется к этому методу
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* Auth + RateLimit */ }
}Middleware класса всегда выполняется раньше middleware метода — независимо от
того, в каком порядке они написаны. Внутри каждой группы порядок объявления
сохраняется: before() идут сверху вниз, after() — в обратном порядке, как
вложенные обёртки:
AuthMiddleware::before() ← сначала класс
RateLimitMiddleware::before() ← затем метод
метод контроллера
RateLimitMiddleware::after()
AuthMiddleware::after() ← и обратно наружуАргументы
Всё, что передано атрибуту, уходит в конструктор middleware. Так один класс обслуживает разные правила:
#[RoleMiddleware('admin')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }Повторное применение
По умолчанию один и тот же middleware вешается на цель один раз. Чтобы применить
его дважды с разными аргументами, добавьте классу флаг IS_REPEATABLE:
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class RoleMiddleware extends Middleware { /* ... */ }
// теперь можно так:
#[RoleMiddleware('admin')]
#[RoleMiddleware('audit')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }Чего middleware не умеет
Три ограничения, о которые чаще всего спотыкаются:
| Ограничение | Что это значит |
|---|---|
| Не наследуется | Middleware, повешенный на базовый контроллер, не переходит на наследников — атрибут читается только с самого класса |
| Нет глобальной регистрации | Повесить middleware «на всё приложение» одной строкой нельзя; его указывают на каждом контроллере |
| Не работает на 404 и 405 | Middleware привязан к обработчику маршрута, поэтому при несовпадении пути ни один before() не вызовется |
Из последнего следует практическое: сквозное логирование или сбор метрик по всем запросам на middleware не построить — такие вещи живут уровнем выше.
Типовые middleware
Три самостоятельных образца на частые задачи. Каждый — готовый файл: копируете, переименовываете под проект, вешаете атрибутом.
Логирование и тайминг
Простейший случай, задействует оба хука. before() запоминает момент старта,
after() считает длительность:
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
use Psr\Log\LoggerInterface;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class TimingMiddleware extends Middleware
{
#[Autowired] private LoggerInterface $logger;
private float $startedAt;
public function before(HttpRequest $request, HttpResponse $response): void
{
$this->startedAt = microtime(true);
$this->logger->info('request', [
'method' => $request->getMethod(),
'uri' => $request->getUri(),
'ip' => $request->getClientIp(),
]);
$response->header('X-Request-Id', bin2hex(random_bytes(8)));
}
public function after(mixed $result): mixed
{
$ms = round((microtime(true) - $this->startedAt) * 1000, 1);
$this->logger->info('response', ['duration_ms' => $ms]);
return $result;
}
}Состояние в свойстве ($startedAt) здесь безопасно: экземпляр middleware строится
заново на каждый запрос, как и контроллер.
Только на маршрутах, которые нашлись
Такой middleware не увидит запросы, для которых маршрут не совпал: на 404 и 405
before() не вызывается. Для учёта совсем всех запросов нужен уровень выше.
Единый конверт ответа
Пример на один after(): привести ответы контроллера к общему формату, не трогая
сами обработчики.
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class EnvelopeMiddleware extends Middleware
{
public function after(mixed $result): mixed
{
if ($result instanceof ResponseEntity) {
return $result->body([
'success' => true,
'data' => $result->getBody(),
]);
}
return ['success' => true, 'data' => $result];
}
}На ответы об ошибках это не распространится — при исключении after() не
выполняется. Единый формат ошибок задаётся отдельно, см.
Обработку ошибок.
Готовое из коробки
Один middleware ядро поставляет само —
Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware. Он берёт часовой
пояс клиента из запроса и включает его на время обработки, откатываясь к
TIME_ZONE из .env, если клиент ничего не прислал:
use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware;
#[ClientTimezoneMiddleware]
#[RequestMapping('reports')]
class ReportController extends Controller { /* ... */ }Внутри обработчика читайте пояс через Timezone::current() — это значение
изолировано по корутинам, поэтому у параллельных запросов оно своё. Подробнее — на
странице Локализация.
Паттерн: аутентификация и права
Дальше — не отдельный образец, а разбор приёма целиком, по шагам. Он решает задачу, которая встаёт почти в каждом проекте: определить, кто прислал запрос, и дать эту информацию всему, что запрос обрабатывает, не протаскивая её аргументами.
Что получится в итоге. Контроллер перестаёт знать про заголовки и токены — он просто спрашивает «кто пришёл» и получает пользователя:
return ResponseEntity::created($this->service->create($this->auth->user()));Из чего это собрано — три обязательные части и одна по желанию:
| Часть | Роль |
|---|---|
| Контекст запроса | Объект, живущий один запрос; хранит, кто пришёл |
| Middleware аутентификации | Опознаёт клиента и записывает результат в контекст |
| Контроллеры и сервисы | Читают контекст, не зная, как он наполнен |
| Middleware прав (опционально) | Проверяет, можно ли конкретное действие |
Это шаблон, а не готовое решение
В примере опознание сделано через JWT, а права — через список строк. И то, и другое — сменные детали. Токен можно заменить сессией, cookie или ключом API, права — ролями или политиками: меняется содержимое шагов 2 и 4, а сам приём остаётся тем же. Важно понять именно расстановку ролей: пишет в контекст только middleware, читают — все остальные.
Шаг 1. Контекст запроса
Начинаем с объекта, куда будет складываться результат опознания. Это обычный класс
— важен только атрибут #[Request] над ним.
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Request;
#[Request]
class AuthContext
{
private ?User $user = null;
public function setUser(User $user): void { $this->user = $user; }
public function user(): User
{
return $this->user ?? throw new \LogicException('No authenticated user');
}
}Почему #[Request]. Этот атрибут говорит контейнеру: один экземпляр на запрос.
Все, кто попросит AuthContext в рамках одного запроса — middleware, контроллер,
сервис — получат тот же самый объект, а параллельные запросы получат каждый
свой (в Swoole изоляция идёт по корутинам).
Без этого атрибута класс считается transient: каждому потребителю достанется
собственный новый экземпляр. Middleware запишет пользователя в свой, контроллер
прочитает из своего — и увидит null. Ошибки при этом не будет никакой, просто
пустой контекст:
#[Request] middleware и контроллер → один объект → user-42
без атрибута middleware и контроллер → разные объекты → nullПочему user() бросает исключение, а не возвращает null. Если до контекста
дошли, значит middleware отработал и пользователь там есть. Пустой контекст в этот
момент — не «неавторизованный запрос», а ошибка в подключении middleware, и лучше
узнать о ней сразу.
Шаг 2. Middleware, который наполняет контекст
Теперь тот, кто опознаёт клиента. Его единственная задача — разобрать входные данные и положить результат в контекст; дальше он в обработке не участвует.
<?php
namespace Main;
use Flytachi\Jwt\Entity\PublicKey;
use Flytachi\Jwt\JWT;
use Flytachi\Jwt\JWTException;
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class JwtMiddleware extends Middleware
{
#[Autowired] private AuthContext $auth;
#[Autowired] private UserRepository $users;
public function before(HttpRequest $request, HttpResponse $response): void
{
$token = Header::getBearerToken();
if ($token === null) {
throw new MiddlewareException('Authorization token is missing'); // 401
}
try {
$payload = JWT::decode($token, [new PublicKey(env('JWT_SECRET'), 'HS256')]);
} catch (JWTException $e) {
throw new MiddlewareException($e->getMessage(), HttpCode::UNAUTHORIZED);
}
$this->auth->setUser($this->users->find($payload->getClaim('sub')));
}
}Что здесь происходит по порядку: достаём токен из заголовка Authorization: Bearer
→ проверяем → загружаем пользователя → кладём его в контекст. Последняя строка
и есть смысл всего шага.
Обратите внимание на два решения:
- Отказ — исключением.
MiddlewareExceptionпо умолчанию даёт401. До контроллера управление не дойдёт, а исключение станет HTTP-ответом само. - Контекст получен через
#[Autowired]. Middleware строит контейнер, поэтому инъекция работает так же, как в контроллере.
JWT здесь взаимозаменяем
Пакет jwt ставится отдельно (composer require flytachi/jwt), а JWT::decode() разом проверяет подпись, алгоритм и сроки — потому
одного catch и хватает. Но для паттерна важно не это: замените тело before() на
проверку сессии или ключа API, оставив последнюю строку — и всё остальное
продолжит работать без единой правки.
Шаг 3. Контроллер читает контекст
Подключаем middleware к контроллеру и запрашиваем контекст тем же #[Autowired]:
#[JwtMiddleware] // весь контроллер под аутентификацией
#[RequestMapping('api/posts')]
class PostController extends Controller
{
#[Autowired] private AuthContext $auth;
#[Autowired] private PostService $service;
#[PostMapping]
public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
{
return ResponseEntity::created(
$this->service->create($req, $this->auth->user())
);
}
}Контроллер не разбирает заголовки, не знает про токены и не проверяет их сам —
к моменту вызова метода middleware уже отработал. Тот же AuthContext можно
попросить и в сервисе: объект один на весь запрос, протаскивать его аргументами
через слои не нужно.
Шаг 4. Проверка прав
Аутентификация отвечает на вопрос «кто это», авторизация — «а можно ли ему». Второе удобно делать отдельным middleware, потому что право у каждого эндпоинта своё. Нужное передаётся аргументом атрибута.
Сначала научим контекст отвечать на вопрос о правах:
public function can(string $permission): bool
{
return in_array($permission, $this->user?->permissions ?? [], true);
}Затем middleware, который этим пользуется:
<?php
namespace Main;
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
#[\Attribute(
\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE
)]
class RequirePermission extends Middleware
{
#[Autowired] private AuthContext $auth;
public function __construct(private string $permission) {}
public function before(HttpRequest $request, HttpResponse $response): void
{
if (!$this->auth->can($this->permission)) {
MiddlewareException::throw(
"Permission '{$this->permission}' required",
HttpCode::FORBIDDEN, // 403
);
}
}
}Применение — по одному праву на строку, флаг IS_REPEATABLE разрешает повторы:
#[JwtMiddleware] // на классе: кто пришёл
#[RequestMapping('api/posts')]
class PostController extends Controller
{
#[RequirePermission('post.edit')] // на методе: что ему можно
#[RequirePermission('post.publish')]
#[PutMapping('{id:\d+}/publish')]
public function publish(#[PathVariable] int $id): ResponseEntity { /* ... */ }
}Почему это работает именно в таком порядке. RequirePermission читает
пользователя из контекста, а значит обязан выполниться после JwtMiddleware.
Так и происходит: middleware класса всегда идут раньше middleware метода. Поэтому
аутентификацию вешают на класс, а проверки прав — на методы.
Список строк — самый простой вариант; заменить его ролями, политиками или запросом
к базе можно, переписав только can() и тело before(). Точка подключения к
эндпоинтам от этого не меняется.
Правила, на которых держится паттерн
Четыре условия — если нарушить любое, приём перестанет работать, причём чаще всего молча:
| Правило | Что будет иначе |
|---|---|
Контекст помечен #[Request] |
Каждый получит свой экземпляр, контроллер прочитает null |
| Пишет в контекст только middleware | Записи из разных мест — и уже не понять, кто положил значение |
| Отказ — через исключение | $response->end() обработку не прерывает, контроллер всё равно выполнится |
| Аутентификация на классе, права на методах | Проверка прав отработает раньше опознания и увидит пустой контекст |
Дальше
- Контроллеры — что middleware оборачивает
- Обработка ошибок — во что превращается брошенное исключение
- Внедрение зависимостей — области видимости и request-scope
- CORS — кросс-доменная политика, отдельный механизм
- Пакет jwt — выпуск и проверка токенов