<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · MIDDLE ЧАСТО ПИТАЮТЬ

Як працюють middleware і в якому порядку вони виконуються?

Middleware — це шари Pipeline навколо контролера: код до `$next($request)` бачить запит, код після — уже готову відповідь. Спершу йдуть глобальні (у порядку реєстрації, ще до роутера), потім групові й маршрутні — але ці фреймворк переставляє за списком пріоритетів, а `terminate()` викликається окремо, вже після відправки відповіді.

Ми додали свій middleware у групу web, а всередині `$request->user()` завжди null — чому так виходить?
Middleware у групі виконуються рівно в тому порядку, в якому я їх записав? Якщо ні, хто цей порядок змінює?
Чим terminable middleware відрізняється від коду після `$next($request)` — і те, і те ж після контролера?
Як виключити один middleware для одного маршруту всередині групи web?
middleware Pipeline bootstrap/app.php priority terminable

Middleware в Laravel — це не «перевірка перед контролером», а шар навколо нього. Ядро складає масив класів і віддає його в Illuminate\Pipeline\Pipeline, який згортає їх у вкладені замикання: кожен handle(Request $request, Closure $next) викликає наступний шар і отримує назад Response. Звідси два проходи в одному методі: усе до $next($request) бачить лише запит і виконується зверху вниз, усе після — вже готову відповідь і виконується знизу вгору. Саме тому «before» і «after» у Laravel не окремі хуки, як у старих фреймворках, а позиція рядка коду відносно $next. Якщо $next не викликати взагалі й повернути власну відповідь, ланцюг обривається — так працюють abort(403), редірект гостя на форму входу й PreventRequestsDuringMaintenance.

Реєструються middleware у чотирьох місцях, і від місця залежить момент виконання. Глобальні ($middleware->append() / prepend() у bootstrap/app.php) працюють на кожен запит ще до того, як роутер знає маршрут: там живуть TrustProxies, HandleCors, ValidatePostSize, TrimStrings. Групи — web(), api(), довільна group('admin', [...]) — застосовуються до всіх маршрутів групи, але вже після пошуку маршруту. Псевдоніми з alias() дають короткі імена для навішування на конкретний маршрут (Route::middleware('team:owner')), а контролер може оголосити свої через статичний метод middleware() інтерфейсу Illuminate\Routing\Controllers\HasMiddleware, зокрема з ->only() і ->except() для окремих екшенів. У Laravel 11+ усе це налаштовується виключно в bootstrap/app.php: класу app/Http/Kernel.php у застосунку більше немає, а стандартні списки лежать у Illuminate\Foundation\Configuration\Middleware.

Порядок — найчастіше місце, де плутаються. Глобальний стек виконується рівно так, як записаний у масиві, і жодного сортування там немає. А от стек маршруту роутер спершу збирає (групові, потім маршрутні, потім контролерні, з дедуплікацією), а вже потім проганяє через SortedMiddleware. Той піднімає вгору лише ті елементи, які знайшов у списку $middlewarePriority з HTTP-ядра: EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, AuthenticatesRequests, ThrottleRequests, AuthenticatesSessions, SubstituteBindings, Authorize. Усі інші лишаються на своїх позиціях. Тому симптом «мій middleware у групі web, а $request->user() порожній» майже завжди означає, що він опинився перед StartSession; лікується не перестановкою рядка в масиві, а appendToPriorityList() / prependToPriorityList(). Повністю переписувати список через priority([...]) заради одного класу небезпечно: легко зламати ланцюг StartSessionSubstituteBindingsAuthorize.

Terminable middleware стоїть осторонь від Pipeline. Якщо в класі є метод terminate($request, $response), ядро викличе його в Kernel::terminate() — після того, як send() уже віддав відповідь клієнту (під PHP-FPM Symfony на цьому місці робить fastcgi_finish_request()). Спочатку обходяться middleware маршруту, потім глобальні; реалізовувати спеціальний інтерфейс не потрібно, перевіряється просто method_exists. Дві пастки. Перша: ядро резолвить клас із контейнера заново, тому властивість, записана в handle(), у terminate() буде порожня — потрібен $this->app->singleton(MyMiddleware::class). Друга: це не черга. Процес FPM усе ще зайнятий, помилку ніхто не повторить, тож туди годяться логи й дрібні метрики, а не відправка листа чи виклик зовнішнього API.

Межі й компроміси варто назвати самому. Middleware добре працює як фільтр запиту й декоратор відповіді — автентифікація, локаль, заголовки безпеки, rate limit; погано — як місце для бізнес-логіки, бо його важко тестувати ізольовано й неможливо перевикористати поза HTTP (черги, консольні команди й Livewire-запити ходять іншими шляхами). Виключення теж асиметричні: withoutMiddleware() знімає лише групові й маршрутні, глобальні прибираються тільки через remove() чи replace() у bootstrap/app.php, а для CSRF і maintenance є точковий except: за URI. І останнє, що варто перевіряти руками, а не в голові: php artisan route:list -v показує фактичний стек для кожного маршруту вже після сортування — це швидший спосіб виграти суперечку про порядок, ніж читати bootstrap/app.php.

// app/Http/Middleware/AuditRequest.php — before, after і terminate в одному класі
final class AuditRequest
{
    private ?float $startedAt = null;

    // 'audit:billing' → у $channel прилетить 'billing'
    public function handle(Request $request, Closure $next, string $channel = 'web'): Response
    {
        $this->startedAt = microtime(true);       // BEFORE: контролера ще не було

        if ($request->user()?->isBanned()) {
            // $next не викликано — ланцюг обірвано, контролер не запуститься
            return response()->view('banned', status: 403);
        }

        $response = $next($request);              // нижчі шари й контролер уже відпрацювали

        return $response->header('X-Audit-Channel', $channel); // AFTER: відповідь ще не відправлена
    }

    // Викликає Kernel::terminate() після send(): клієнт відповідь уже отримав
    public function terminate(Request $request, Response $response): void
    {
        Log::channel('audit')->info($request->path(), [
            'status' => $response->getStatusCode(),
            // без singleton нижче тут буде null: ядро робить app->make()
            // і в terminate() потрапляє НОВИЙ екземпляр класу
            'ms' => $this->startedAt ? (microtime(true) - $this->startedAt) * 1000 : null,
        ]);
    }
}

// AppServiceProvider::register() — щоб terminate() побачив той самий $startedAt
$this->app->singleton(AuditRequest::class);

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->append(SecurityHeaders::class);              // глобальний: до роутера, на кожен запит
    $middleware->web(append: [AuditRequest::class]);          // уся група web
    $middleware->alias(['team' => EnsureTeamMatches::class]); // Route::middleware('team:owner')
    // без цього рядка team може стати ДО SubstituteBindings і не побачити модель
    $middleware->appendToPriorityList(SubstituteBindings::class, EnsureTeamMatches::class);
})
Що middleware — не «фільтр перед контролером», а шар цибулини: один метод `handle()` містить і before-код (до `$next`), і after-код (після `$next`), який працює вже з `Response`.
Чотири рівні реєстрації в Laravel 11+: глобальний стек (`append`/`prepend`), групи (`web()`, `api()`, `group()`), псевдоніми (`alias()`) для маршрутів і `HasMiddleware` на контролері.
Що порядок усередині маршруту не дорівнює порядку запису: `Router::sortMiddleware()` через `SortedMiddleware` переставляє ті middleware, що є в списку пріоритетів (`StartSession`, `SubstituteBindings`, `Authorize` і т. д.), а решта лишається на своїх місцях.
Що `terminate($request, $response)` викликає ядро після `send()`, і ядро резолвить клас із контейнера заново — без `singleton()` це інший екземпляр, ніж той, що виконував `handle()`.
Що глобальні middleware пріоритетами не сортуються взагалі: для них діє лише порядок у масиві, тому `prepend()` і `append()` тут — єдиний важіль.
Ставити свій middleware у групу web і чекати на `$request->user()`: без пріоритету він може опинитися перед `StartSession`, і сесії ще не існує.
Читати `$request->route('post')` як модель у middleware, який стоїть до `SubstituteBindings`: там ще рядок з URL, а не Eloquent-модель.
Класти важку роботу в `terminate()`, вважаючи його «майже чергою»: він виконується в тому ж процесі FPM, без ретраїв, і тримає воркер зайнятим.
Зберігати стан у властивості middleware й читати його в `terminate()`, не зареєструвавши клас через `$this->app->singleton()` — властивість буде порожня.
Замінювати весь список пріоритетів через `$middleware->priority([...])` заради одного свого класу: так легко втратити пару `StartSession` → `SubstituteBindings` → `Authorize`, замість цього є `appendToPriorityList()` і `prependToPriorityList()`.
Думати, що `withoutMiddleware()` знімає й глобальні middleware: він працює лише зі стеком маршруту (групові й маршрутні), глобальні виключаються тільки на рівні `bootstrap/app.php` через `remove()`.
ПОРАДА

Дайте дві осі одразу: вертикаль — чотири місця реєстрації (глобально → група → alias на маршруті → контролер), горизонталь — два проходи всередині кожного шару (до `$next` і після `$next`), плюс `terminate()` окремо після відправки. І одразу додайте, що всередині маршруту порядок вирішує не ваш масив, а `$middlewarePriority`.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Глобальний стек виконується в порядку масиву й пріоритетами не сортується. Стек маршруту (група + маршрут + контролер) проходить через SortedMiddleware, який піднімає елементи зі списку $middlewarePriority. terminate() працює вже після send(), змінювати відповідь там немає сенсу.