<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SYMFONY · MIDDLE

Кернел-події Symfony: коли слухач, а коли підписник, і що робити в kernel.exception?

Слухач і підписник - це один і той самий механізм із різним місцем конфігурації: підписник оголошує події й пріоритети всередині класу, слухач отримує їх ззовні (атрибут, YAML), і це зручніше для чужих подій та для налаштувань, що змінюються без правки коду. У `kernel.exception` головне - пріоритет (нижче нуля, щоб не з'їсти логування), `setThrowable()` для перетворення доменного винятку на HTTP-виняток і памʼять про те, що `setResponse()` зупиняє поширення події.

Чим `#[AsEventListener]` відрізняється від `EventSubscriberInterface` і що ви берете за замовчуванням?
Ми перехопили `kernel.exception` і віддаємо JSON, але в Sentry й у логах цих помилок більше немає. Чому?
Ми повертаємо з kernel.exception відповідь із кодом 200, а клієнт отримує 500. Хто його переписав?
У Laravel я вішав middleware на групу маршрутів. Як зробити те саме в Symfony?
EventDispatcher kernel.exception priority listener subscriber domain events

Слухач і підписник відрізняються тільки місцем, де лежить конфігурація. Підписник реалізує EventSubscriberInterface і сам відповідає на питання «які події, які методи, які пріоритети» у getSubscribedEvents(). Слухач нічого про себе не знає: подію, метод і пріоритет йому призначають ззовні, атрибутом #[AsEventListener] (Symfony 6+) або тегом kernel.event_listener у services.yaml. Далі autoconfigure зводить обидва варіанти до одного тега, а RegisterListenersPass кладе їх в один диспетчер, обгорнутими в ліниві замикання: сервіс створюється лише тоді, коли подія справді диспатчиться. Вибір, отже, суто організаційний. Своя подія й свій код - беріть підписник, бо конфіг лежить поруч і не губиться. Для реакції на подію чужого бандла, різних пріоритетів у різних середовищах чи вмикання через параметр зручніший слухач: конфіг тоді не зашитий у клас.

Пріоритет працює як звичайне сортування: більше число виконується раніше, за замовчуванням 0, при рівних числах вирішує порядок реєстрації. Числа починають важити, коли ви вбудовуєтесь між чужими слухачами. RouterListener стоїть на kernel.request з пріоритетом 32, тож усе, що читає _route, має бути нижче, а все, що має відпрацювати до маршрутизації (maintenance mode, дешевий rate limit по IP), вище. Обірвати ланцюжок можна двома способами: явним stopPropagation() або setResponse(), який усередині RequestEvent викликає stopPropagation() сам. Друге легко забувається і дає найтиповіший баг з кернел-подіями: хтось поставив свій обробник вище й ненавмисно вимкнув усіх, хто нижче.

У kernel.exception це б'є найбільше, бо там сидить ErrorListener із двома підписками: logKernelException на пріоритеті 0 і onKernelException на -128. Логування йде першим, рендеринг сторінки помилки останнім, а місце для вашого обробника між ними, десь на -64. Поставите вище нуля з setResponse() - отримаєте красивий JSON і тишу в логах. Про сам handleThrowable() треба тримати в голові три речі. По-перше, замінити виняток можна тільки через setThrowable(): новий throw усередині слухача через цю подію вже не пройде. По-друге, якщо жоден слухач не поставив відповідь, ядро кидає виняток далі, і його ловить уже глобальний обробник помилок. По-третє, статус: коли відповідь не 4xx, не 5xx і не redirect, ядро перезапише код на getStatusCode() з HttpExceptionInterface або на 500; свідомо віддати 200 чи 202 можна лише після allowCustomResponseCode().

Порівняння з Laravel корисне саме тим, де воно ламається. Middleware працює як onion: один клас бачить запит, викликає $next($request) і бачить відповідь, а прив'язка до маршруту чи групи декларативна. Кернел-подія такої симетрії не дає: «до» живе в kernel.request, «після» в kernel.response, це два методи, і стан між ними передається через $request->attributes. Прив'язки до маршруту теж немає, слухач глобальний і сам вирішує, чи його стосується поточний запит (перевіркою шляху, _route, кастомного defaults у маршруті або RequestMatcher). Зате є те, чого немає в middleware: точки kernel.controller, kernel.controller_arguments і kernel.view, де можна підмінити контролер, дописати аргументи чи перетворити повернений об'єкт на відповідь. Справжній аналог onion у Symfony є, але на іншому рівні: декорування HttpKernelInterface (HttpCache працює саме так) і middleware в Messenger.

Доменні події спокійно ходять через той самий EventDispatcher, доки ви не плутаєте їх із кернел-подіями. З Symfony 4.3 подія це звичайний об'єкт без успадкування, а ім'я події дорівнює імені класу; Symfony\Contracts\EventDispatcher\EventDispatcherInterface розширює PSR-14, тому доменний код може залежати від інтерфейсу PSR і не тягнути фреймворк. Два обмеження стріляють регулярно: диспетчер синхронний (виняток у слухачі валить весь запит, повільний слухач додається до часу відповіді) і момент диспатчу. Подію треба публікувати після коміту, а не поруч із persist(), інакше листи й вебхуки підуть по даних, яких після rollback не існує. Практичний рецепт: агрегат накопичує події в собі, слухач на Doctrine postFlush або Messenger із dispatch_after_current_bus віддає їх назовні, а все повільне з обробника одразу йде в чергу.

// src/EventSubscriber/ApiExceptionSubscriber.php
final class ApiExceptionSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        // -64: нижче logKernelException (0), тому виняток усе одно потрапить у лог,
        // але вище ErrorListener::onKernelException (-128), який віддав би HTML-сторінку
        return [KernelEvents::EXCEPTION => ['onException', -64]];
    }

    public function onException(ExceptionEvent $event): void
    {
        if (!str_starts_with($event->getRequest()->getPathInfo(), '/api/')) {
            return; // HTML-частину сайту лишаємо стандартному обробнику
        }

        $throwable = $event->getThrowable();

        if ($throwable instanceof OrderAlreadyConfirmed) {
            // не кидаємо новий виняток, а підміняємо: інакше він обмине цю подію
            $throwable = new ConflictHttpException($throwable->getMessage(), $throwable);
            $event->setThrowable($throwable);
        }

        $status = $throwable instanceof HttpExceptionInterface ? $throwable->getStatusCode() : 500;

        // код 4xx/5xx у самій відповіді, інакше HttpKernel перепише його на код винятку
        $event->setResponse(new JsonResponse([
            'title' => Response::$statusTexts[$status] ?? 'Error',
            'detail' => $status < 500 ? $throwable->getMessage() : 'Internal server error',
        ], $status)); // setResponse зупиняє поширення: ErrorListener уже не спрацює
    }
}

// src/EventListener/LocaleListener.php - те саме без інтерфейсу, конфіг в атрибуті
#[AsEventListener(event: KernelEvents::REQUEST, priority: 33)] // 33 > 32 у RouterListener
final class LocaleListener
{
    public function __invoke(RequestEvent $event): void
    {
        if ($event->isMainRequest()) {
            $request = $event->getRequest();
            $request->setLocale($request->getPreferredLanguage(['uk', 'en']));
        }
    }
}
Що в рантаймі різниці між listener і subscriber немає: обидва потрапляють у той самий диспетчер тим самим тегом `kernel.event_listener` і обидва інстанціюються ліниво.
Що вищий пріоритет виконується першим, дефолт - 0, а при рівних пріоритетах порядок визначає порядок реєстрації.
Що `RequestEvent::setResponse()` (а отже й `ExceptionEvent::setResponse()`) сам викликає `stopPropagation()`, тому слухачі з нижчим пріоритетом уже не спрацюють.
Що `ErrorListener` слухає `kernel.exception` двічі: `logKernelException` на пріоритеті 0 і `onKernelException` на -128; власний обробник відповіді має жити між ними.
Що `HttpKernel::handleThrowable()` перезаписує код відповіді на код `HttpExceptionInterface` (або 500), якщо відповідь не 4xx/5xx/redirect і не викликано `allowCustomResponseCode()`.
Що `kernel.request` - це не middleware: слухач глобальний і сам вирішує, чи його стосується поточний маршрут, тоді як middleware в Laravel декларативно вішається на маршрут і обгортає обробку з обох боків.
Вішати обробник `kernel.exception` із додатним пріоритетом і `setResponse()`: подія зупиняється до `logKernelException`, і виняток зникає з логів та з Sentry.
Повертати з обробника винятку `new JsonResponse([...], 200)` і не розуміти, чому приходить 500: код перепишеться, поки не викликано `allowCustomResponseCode()`.
Кидати новий виняток усередині слухача `kernel.exception` замість `setThrowable()` - цей виняток уже не пройде через подію ще раз.
Ловити в `kernel.request` факт «маршрут X», не перевіривши пріоритет: до `RouterListener` (32) атрибута `_route` ще немає.
Диспатчити доменну подію всередині сутності або одразу після `persist()`, коли транзакція ще не закомічена, і надсилати лист про замовлення, якого в базі не буде.
Робити підписник із `getSubscribedEvents()`, який повертає події чужого бандла, а потім не мати змоги вимкнути його в одному середовищі: конфіг зашитий у клас.
ПОРАДА

Розкажіть про пріоритети конкретними числами й іменами: «`RouterListener` - 32, `logKernelException` - 0, `ErrorListener::onKernelException` - -128, тому свій API-обробник винятків я ставлю приблизно на -64». Дві цифри й одна назва класу переконують більше, ніж переказ документації.

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

RequestEvent::setResponse() викликає stopPropagation(), а ErrorListener логує на пріоритеті 0 і рендерить сторінку помилки на -128; власний обробник має стояти між ними.