Middleware
Middleware (промежуточное ПО) — это фильтры, которые обрабатывают запросы до того, как они попадут в контроллер, и после того, как контроллер сформирует ответ.
Зачем нужен Middleware
Типичные задачи для middleware:
- Проверка авторизации — пустить только авторизованных пользователей
- Проверка прав — пустить только администраторов
- Защита от CSRF — проверить токен формы
- Ограничение запросов — защита от спама и DDoS
- Логирование — записать информацию о запросе
- Кеширование — вернуть закешированный ответ
Как это работает
Запрос пользователя
↓
┌─────────────────┐
│ Middleware 1 │ ← Проверка авторизации
└────────┬────────┘
↓
┌─────────────────┐
│ Middleware 2 │ ← Проверка прав доступа
└────────┬────────┘
↓
┌─────────────────┐
│ Контроллер │ ← Обработка запроса
└────────┬────────┘
↓
┌─────────────────┐
│ Middleware 2 │ ← Можно модифицировать ответ
└────────┬────────┘
↓
┌─────────────────┐
│ Middleware 1 │ ← Можно модифицировать ответ
└────────┬────────┘
↓
Ответ пользователюКаждый middleware может:
- Пропустить запрос дальше (вызвать
$next($request)) - Прервать цепочку и вернуть свой ответ (например, “Доступ запрещён”)
Встроенные middleware
Flute CMS предоставляет готовые middleware для типичных задач:
Группы middleware
| Группа | Что входит | Когда использовать |
|---|---|---|
default | ban.check, throttle, maintenance | Применяется автоматически ко ВСЕМ маршрутам |
web | csrf, throttle | Для обычных страниц с формами |
api | throttle, ban.check | Для API-эндпоинтов |
Отдельные middleware
| Middleware | Что делает | Пример использования |
|---|---|---|
auth | Пускает только авторизованных | #[Middleware(['auth'])] |
guest | Пускает только гостей | Страница логина |
csrf | Проверяет CSRF-токен | Защита форм |
can:право | Проверяет конкретное право | #[Middleware(['can:manage_users'])] |
throttle | Ограничивает кол-во запросов | Защита от спама |
token | Проверяет API-токен | API-авторизация |
htmx | Специальная обработка HTMX | Для HTMX-запросов |
Группа default применяется всегда. Даже если вы укажете withoutMiddleware(['throttle']), элементы из default не уберутся.
Настройка throttle
Middleware throttle защищает от слишком частых запросов:
// Формат: throttle:стратегия,лимит,интервал,политика
// 60 запросов в минуту по IP
#[Middleware(['throttle:ip,60,60'])]
// 120 запросов в минуту по пользователю
#[Middleware(['throttle:user,120,60'])]
// Комбинация IP + пользователь
#[Middleware(['throttle:ip_user,100,60'])]Параметры:
- Стратегия:
ip,user,ip_user - Лимит: количество разрешённых запросов
- Интервал: период в секундах (или строка:
1 minute) - Политика:
fixed_window,sliding_window,token_bucket
Использование middleware
На всём контроллере
Атрибут #[Middleware] на классе применяется ко ВСЕМ методам:
<?php
namespace Flute\Modules\Blog\Http\Controllers;
use Flute\Core\Router\Annotations\Get;
use Flute\Core\Router\Annotations\Middleware;
use Flute\Core\Support\BaseController;
/**
* Все методы этого контроллера требуют авторизации.
*/
#[Middleware(['auth'])]
class ProfileController extends BaseController
{
#[Get('/profile')]
public function index()
{
// Сюда попадут только авторизованные пользователи
return response()->view('profile::index');
}
#[Get('/profile/settings')]
public function settings()
{
// И сюда тоже
return response()->view('profile::settings');
}
}На отдельном методе
Можно добавить middleware только к конкретному методу:
#[Middleware(['web'])]
class ArticleController extends BaseController
{
/**
* Просмотр статьи — доступен всем.
*/
#[Get('/articles/{id}')]
public function show(int $id)
{
// middleware: web
}
/**
* Редактирование статьи — только автору или админу.
*/
#[Get('/articles/{id}/edit')]
#[Middleware(['auth', 'can:edit_articles'])]
public function edit(int $id)
{
// middleware: web + auth + can:edit_articles
}
/**
* Удаление — только с CSRF-проверкой.
*/
#[Post('/articles/{id}/delete')]
#[Middleware(['auth', 'csrf'])]
public function delete(int $id)
{
// middleware: web + auth + csrf
}
}В файлах маршрутов
<?php
// Отдельный маршрут с middleware
router()->get('/admin', [AdminController::class, 'index'])
->middleware(['auth', 'can:admin_access']);
// Группа маршрутов — middleware применяется ко всем
router()->group(['middleware' => ['auth']], function () {
router()->get('/dashboard', [DashboardController::class, 'index']);
router()->get('/settings', [SettingsController::class, 'index']);
});
// Вложенные группы
router()->group(['prefix' => '/api', 'middleware' => ['api']], function () {
// Публичные эндпоинты
router()->get('/articles', [ArticleController::class, 'index']);
// Защищённые эндпоинты
router()->group(['middleware' => ['auth']], function () {
router()->post('/articles', [ArticleController::class, 'store']);
router()->delete('/articles/{id}', [ArticleController::class, 'destroy']);
});
});Создание своего middleware
Базовая структура
Создайте класс, реализующий MiddlewareInterface:
<?php
namespace Flute\Modules\Blog\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class CheckArticleOwnerMiddleware implements MiddlewareInterface
{
/**
* Обработка запроса.
*
* @param FluteRequest $request Объект запроса
* @param Closure $next Следующий middleware в цепочке
* @param mixed ...$args Дополнительные параметры
* @return Response
*/
public function handle(FluteRequest $request, Closure $next, ...$args): Response
{
// 1. Выполняем проверки ДО контроллера
// Получаем ID статьи из URL
$articleId = $request->getAttribute('id');
// Загружаем статью
$article = rep(Article::class)->findByPK($articleId);
// Статья не найдена?
if (!$article) {
return response()->json(['error' => 'Статья не найдена'], 404);
}
// Пользователь — не автор и не админ?
if ($article->author_id !== user()->id && !user()->can('manage_articles')) {
return response()->json(['error' => 'Нет прав на редактирование'], 403);
}
// 2. Можно передать данные в контроллер через атрибуты запроса
$request->attributes->set('article', $article);
// 3. Пропускаем запрос дальше
$response = $next($request);
// 4. Можно модифицировать ответ ПОСЛЕ контроллера
// $response->headers->set('X-Custom-Header', 'value');
return $response;
}
}Регистрация middleware
Зарегистрируйте middleware в провайдере модуля:
<?php
namespace Flute\Modules\Blog\Providers;
use Flute\Core\Support\ModuleServiceProvider;
use Flute\Modules\Blog\Middleware\CheckArticleOwnerMiddleware;
class BlogProvider extends ModuleServiceProvider
{
public function boot(\DI\Container $container): void
{
// Регистрируем middleware под коротким именем (алиасом)
router()->aliasMiddleware('article.owner', CheckArticleOwnerMiddleware::class);
$this->bootstrapModule();
}
}Использование своего middleware
#[Get('/articles/{id}/edit')]
#[Middleware(['auth', 'article.owner'])] // Используем наш алиас
public function edit(int $id)
{
// Статья уже загружена в middleware!
$article = request()->getAttribute('article');
return response()->view('blog::edit', compact('article'));
}Примеры middleware
Логирование запросов
Записываем информацию о каждом запросе:
<?php
namespace Flute\Modules\Analytics\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class RequestLoggerMiddleware implements MiddlewareInterface
{
public function handle(FluteRequest $request, Closure $next, ...$args): Response
{
// Засекаем время начала
$startTime = microtime(true);
// Логируем входящий запрос
logs('requests')->info('Запрос начат', [
'method' => $request->getMethod(),
'url' => $request->getRequestUri(),
'ip' => $request->getClientIp(),
'user_id' => user()->isLoggedIn() ? user()->id : null,
]);
// Пропускаем запрос дальше
$response = $next($request);
// Считаем время выполнения
$duration = round((microtime(true) - $startTime) * 1000, 2);
// Логируем результат
logs('requests')->info('Запрос завершён', [
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
]);
return $response;
}
}Кеширование страниц
Возвращаем закешированный ответ для GET-запросов:
<?php
namespace Flute\Modules\Cache\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class PageCacheMiddleware implements MiddlewareInterface
{
public function handle(FluteRequest $request, Closure $next, int $ttl = 3600): Response
{
// Кешируем только GET-запросы
if ($request->getMethod() !== 'GET') {
return $next($request);
}
// Не кешируем для авторизованных (у них может быть персональный контент)
if (user()->isLoggedIn()) {
return $next($request);
}
// Формируем ключ кеша
$cacheKey = 'page_' . md5($request->getRequestUri());
// Проверяем кеш
$cached = cache()->get($cacheKey);
if ($cached) {
return $cached; // Возвращаем из кеша
}
// Выполняем запрос
$response = $next($request);
// Кешируем только успешные ответы
if ($response->getStatusCode() === 200) {
cache()->set($cacheKey, $response, $ttl);
}
return $response;
}
}Использование:
#[Get('/articles')]
#[Middleware(['page.cache:1800'])] // Кешировать на 30 минут
public function index() { }Проверка подписки
Пускаем только пользователей с активной подпиской:
<?php
namespace Flute\Modules\Premium\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class RequireSubscriptionMiddleware implements MiddlewareInterface
{
public function handle(FluteRequest $request, Closure $next, string $plan = null): Response
{
// Проверяем авторизацию
if (!user()->isLoggedIn()) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Требуется авторизация'], 401);
}
return redirect(route('login'));
}
// Проверяем подписку
$subscription = user()->getSubscription();
if (!$subscription || !$subscription->isActive()) {
if ($request->expectsJson()) {
return response()->json(['error' => 'Требуется подписка'], 403);
}
return redirect(route('subscription.plans'))
->with('error', 'Для доступа к этому разделу нужна подписка');
}
// Проверяем конкретный план (если указан)
if ($plan && $subscription->plan !== $plan) {
return response()->json([
'error' => "Требуется план: {$plan}"
], 403);
}
return $next($request);
}
}Использование:
#[Get('/premium/content')]
#[Middleware(['subscription'])] // Любая подписка
public function premiumContent() { }
#[Get('/vip/content')]
#[Middleware(['subscription:vip'])] // Только VIP-план
public function vipContent() { }CORS для API
Разрешаем запросы с других доменов:
<?php
namespace Flute\Modules\Api\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class CorsMiddleware implements MiddlewareInterface
{
// Разрешённые домены (* = все)
private array $allowedOrigins = ['*'];
// Разрешённые HTTP-методы
private array $allowedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
// Разрешённые заголовки
private array $allowedHeaders = ['Content-Type', 'Authorization', 'X-Requested-With'];
public function handle(FluteRequest $request, Closure $next, ...$args): Response
{
// Preflight-запрос (браузер проверяет, можно ли делать запрос)
if ($request->getMethod() === 'OPTIONS') {
$response = new Response('', 200);
return $this->addCorsHeaders($response);
}
// Обычный запрос
$response = $next($request);
return $this->addCorsHeaders($response);
}
private function addCorsHeaders(Response $response): Response
{
$response->headers->set(
'Access-Control-Allow-Origin',
implode(', ', $this->allowedOrigins)
);
$response->headers->set(
'Access-Control-Allow-Methods',
implode(', ', $this->allowedMethods)
);
$response->headers->set(
'Access-Control-Allow-Headers',
implode(', ', $this->allowedHeaders)
);
$response->headers->set('Access-Control-Max-Age', '86400');
return $response;
}
}Определение языка
Автоматически определяем язык пользователя:
<?php
namespace Flute\Modules\I18n\Middleware;
use Closure;
use Flute\Core\Router\Contracts\MiddlewareInterface;
use Flute\Core\Support\FluteRequest;
use Symfony\Component\HttpFoundation\Response;
class DetectLocaleMiddleware implements MiddlewareInterface
{
private array $supportedLocales = ['ru', 'en', 'de'];
public function handle(FluteRequest $request, Closure $next, ...$args): Response
{
// Приоритет определения языка:
// 1. Параметр в URL (?lang=en)
// 2. Сохранённый в сессии
// 3. Из заголовка браузера
// 4. Язык по умолчанию
$locale = $request->get('lang')
?? session()->get('locale')
?? $this->detectFromBrowser($request)
?? config('app.locale', 'ru');
// Проверяем, поддерживается ли язык
if (!in_array($locale, $this->supportedLocales)) {
$locale = 'ru';
}
// Устанавливаем язык
app()->setLocale($locale);
session()->set('locale', $locale);
return $next($request);
}
private function detectFromBrowser(FluteRequest $request): ?string
{
$header = $request->headers->get('Accept-Language');
if (!$header) {
return null;
}
// Парсим заголовок: "ru-RU,ru;q=0.9,en;q=0.8"
foreach (explode(',', $header) as $part) {
$locale = explode(';', trim($part))[0];
$shortLocale = explode('-', $locale)[0];
if (in_array($shortLocale, $this->supportedLocales)) {
return $shortLocale;
}
}
return null;
}
}Создание группы middleware
Если несколько middleware часто используются вместе, создайте группу:
public function boot(\DI\Container $container): void
{
// Регистрируем отдельные middleware
router()->aliasMiddleware('subscription', RequireSubscriptionMiddleware::class);
router()->aliasMiddleware('request.log', RequestLoggerMiddleware::class);
// Создаём группу
router()->middlewareGroup('premium.api', [
'api',
'auth',
'subscription',
'request.log',
'throttle:user,100,60'
]);
$this->bootstrapModule();
}Использование:
#[Middleware(['premium.api'])]
class PremiumApiController extends BaseController
{
// Все middleware из группы применяются автоматически
}Советы
Производительность
- Кешируйте тяжёлые проверки — если проверяете права, кешируйте результат
- Не делайте запросы к БД без необходимости — проверьте сначала простые условия
- Используйте ленивую загрузку — не загружайте сервисы, пока они не нужны
Безопасность
- Всегда валидируйте входные данные — даже в middleware
- Логируйте подозрительную активность — слишком много ошибок авторизации, странные запросы
- Не доверяйте данным из запроса — заголовки и параметры могут быть подделаны
Организация кода
- Один middleware — одна задача — не смешивайте проверку авторизации с логированием
- Давайте понятные имена —
check.subscriptionлучше, чемmw1 - Документируйте параметры — если middleware принимает параметры, опишите их