События и подписчики в Symfony: чек-лист перед продакшеном
Слушатель, который делает HTTP-запрос внутри kernel.request, добавляет эти миллисекунды к каждому запросу приложения. Разбираем, что проверить в событийной подсистеме до выката.

Событийная подсистема Symfony тихо разрастается. Сначала один слушатель на kernel.exception, потом подписчик на события Doctrine, потом кто-то повесил отправку письма прямо в kernel.terminate — и вот вы уже не знаете, что и в каком порядке выполняется на каждом запросе. Проблемы всплывают в проде: лишние SQL-запросы, слушатель, который ловит все запросы вместо API-роутов, событие, которое не диспатчится в очереди. Ниже — чек-лист того, что стоит проверить перед выкатом. Материал для Symfony 6.x и 7.x; сигнатуры событий ядра в этих версиях совпадают.
Разбор идёт про слушатели и подписчики — регистрацию, приоритеты, стоимость на каждом запросе. Асинхронная обработка через Messenger — смежная тема, здесь её не разворачиваем: если событие уходит в очередь, это уже сообщение, а не листенер синхронного цикла.
Слушатель или подписчик: где что регистрируется
В Symfony два способа подключить обработчик к событию, и они по-разному видны при ревью.
EventSubscriber — класс реализует EventSubscriberInterface и сам объявляет, на что подписан, через статический метод getSubscribedEvents(). Список событий и приоритеты лежат в самом классе — открыл файл и видишь всю картину.
EventListener с атрибутом #[AsEventListener] (доступен с Symfony 6.1) — привязка объявляется на классе или методе. Альтернатива — тег kernel.event_listener в конфигурации сервисов.
// Symfony 6.1+ — атрибут на методе
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class ApiLocaleListener
{
#[AsEventListener(event: KernelEvents::REQUEST, priority: 20)]
public function onKernelRequest(RequestEvent $event): void
{
// Обрабатываем только основной запрос, не подзапросы
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
// Реагируем только на API-роуты, а не на весь трафик
if (!str_starts_with($request->getPathInfo(), '/api')) {
return;
}
$request->setLocale($request->getPreferredLanguage(['ru', 'en']));
}
}
Первая проверка на ревью — ранний выход. Слушатель kernel.request вызывается на каждом запросе: статика, health-check, ассеты dev-панели. Если внутри нет проверки isMainRequest() и фильтра по пути, обработчик отработает там, где не должен.
Приоритеты: порядок, который легко сломать
Приоритет — целое число, чем больше, тем раньше вызывается слушатель. По умолчанию 0. Проблема в том, что приоритеты разбросаны по разным классам и бандлам, и их фактический порядок нигде не собран в одном месте — кроме дампа.
Перед продом посмотрите реальную цепочку для нужного события:
# Все слушатели kernel.request в порядке вызова
php bin/console debug:event-dispatcher kernel.request
# Конкретное имя события целиком
php bin/console debug:event-dispatcher "Symfony\Component\HttpKernel\Event\RequestEvent"
Команда выводит слушателей с приоритетами в том порядке, в котором они сработают. Типичная ошибка — повесить свою проверку доступа с приоритетом ниже, чем у файрвола Security, и удивляться, что запрос уже отклонён до вашего кода. Файрвол Security слушает kernel.request с высоким приоритетом; если ваша логика должна отработать после аутентификации, приоритет нужен ниже него, а точное число смотрите в дампе, а не подбирайте наугад.
Приоритет, вписанный по памяти, — это скрытая зависимость от чужого кода. Через год бандл обновится, сдвинет свой слушатель, и ваш порядок молча сломается. Дамп debug:event-dispatcher в CI дешевле, чем разбор инцидента в проде.
События Doctrine: где прячутся лишние запросы
Отдельный источник боли — подписчики на события ORM (postPersist, postUpdate, события через атрибут #[AsDoctrineListener] или lifecycle callbacks). Внутри такого обработчика легко вызвать новый запрос к базе или, хуже, изменить сущность так, что Doctrine запустит ещё один flush.
Что проверить:
- Нет обращений к репозиторию внутри postFlush/postPersist без нужды. Один такой вызов в цикле сохранения превращается в N+1 на ровном месте.
- Изменение сущности внутри listener сделано корректно. Менять поля в
preUpdateнужно черезPreUpdateEventArgsи его API пересчёта changeset, а не прямым сеттером — иначе изменение не попадёт в SQL. Точный способ уточните в разделе Doctrine ORM про lifecycle events для вашей версии ORM. - Тяжёлая работа вынесена из синхронного цикла. Отправка письма или обращение к внешнему API прямо в
postPersistблокирует ответ пользователю. Диспатчьте сообщение в Messenger, а слушатель пусть только кладёт его в очередь.
Сколько это стоит на каждом запросе
Слушатели kernel.request и kernel.controller выполняются синхронно до того, как контроллер начнёт отвечать. Их время складывается в задержку каждого запроса. Смотреть не «на глаз», а по профилю.
В dev-окружении откройте Symfony Profiler, секцию с таймлайном (панель Performance) — там видно, сколько занял каждый слушатель. Для нагрузочной картины включите Blackfire или Xdebug-профилирование на стейджинге с реалистичным объёмом данных. Придумывать конкретные миллисекунды тут бессмысленно: они зависят от того, ходит ли слушатель в базу и в кеш. Меряйте на своём железе и своём трафике.
| Что делает слушатель | Риск на каждом запросе | Куда смотреть |
|---|---|---|
| Только читает заголовки/путь | Низкий | Проверить ранний выход |
| Обращается к кешу (Redis) | Средний, при недоступности кеша — рост latency | Таймауты и фолбэк |
| Делает SQL-запрос | Высокий, умножается на RPS | Кешировать или убрать из цикла запроса |
| Ходит во внешний HTTP | Критический, чужой таймаут = ваш 502 | Вынести в Messenger |
Продовые окружения и dev-only слушатели
Слушатель, полезный в разработке, не должен попасть в прод. Профайлерные и отладочные обработчики регистрируются под конкретное окружение через конфигурацию. Проверьте, что кастомные dev-слушатели ограничены окружением и не тегированы глобально.
# config/services_dev.yaml — только для dev
services:
App\EventListener\DebugTimingListener:
tags:
- { name: kernel.event_listener, event: kernel.terminate }
После деплоя прогрейте кеш контейнера и убедитесь, что список слушателей в проде именно тот, что ожидали:
APP_ENV=prod php bin/console debug:event-dispatcher kernel.request
Чек-лист перед выкатом
- Каждый слушатель
kernel.requestимеет проверкуisMainRequest()и фильтр по пути/роуту. - Приоритеты сверены дампом
debug:event-dispatcher, а не вписаны по памяти. Порядок относительно Security-файрвола проверен явно. - Внутри Doctrine-подписчиков нет незапланированных обращений к базе; изменение сущностей сделано через API соответствующего события.
- Тяжёлые действия (письма, внешние API) вынесены в Messenger, а не выполняются синхронно в цикле запроса.
- Профиль слушателей снят на стейджинге с реальным объёмом данных; latency видна, а не угадана.
- Dev-слушатели ограничены окружением и отсутствуют в проде — подтверждено дампом с
APP_ENV=prod. - При исключении внутри слушателя понятно, что произойдёт: остановится ли обработка, отдастся ли 500. Слушатель
kernel.exceptionне глотает ошибки молча.
Что настроить дальше
После выката держите под наблюдением p95-latency роутов, на которых висит больше всего слушателей — именно там всплывёт лишний SQL или медленный кеш. Заведите в CI шаг, который дампит debug:event-dispatcher для ключевых событий: изменение порядка после обновления зависимостей станет видно на код-ревью, а не в инциденте. Если синхронный цикл всё равно упирается в отправку писем и внешние вызовы — следующий шаг это Symfony Messenger с отдельным транспортом, но это уже отдельная тема.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Symfony Documentation — Events and Event Listeners, Symfony Documentation — Built-in Symfony Events (KernelEvents)
Частые вопросы
У меня Symfony 5.4, а атрибут #[AsEventListener] не работает. Что делать?
Слушатель kernel.request не срабатывает — в чём частая причина?
Чем EventSubscriber лучше слушателя с атрибутом?
Изменил поле сущности в preUpdate, но в базу оно не попало. Почему?
Как понять реальный порядок слушателей в проде, а не в dev?
Опасно ли делать HTTP-запрос к внешнему сервису внутри слушателя kernel.request?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.