Слухач і підписник відрізняються тільки місцем, де лежить конфігурація. Підписник реалізує 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']));
}
}
}
Розкажіть про пріоритети конкретними числами й іменами: «`RouterListener` - 32, `logKernelException` - 0, `ErrorListener::onKernelException` - -128, тому свій API-обробник винятків я ставлю приблизно на -64». Дві цифри й одна назва класу переконують більше, ніж переказ документації.