События и подписчики в Symfony: где всё ломается на практике
EventListener и EventSubscriber кажутся простыми, пока порядок вызова не начинает зависеть от загрузки бандлов, а бизнес-логика тихо не срабатывает дважды. Разбираем частые грабли Symfony 7.x.

Вы подписались на kernel.request, а слушатель молчит. Или наоборот — сработал там, где вы его не ждали: на профайлере, на ассетах, на favicon.ico. Или подписчик выполнил бизнес-логику дважды, потому что событие бросается в цикле. Всё это — типовые ошибки при работе с EventDispatcher в Symfony, и почти каждая связана с непониманием того, когда и в каком порядке вызываются обработчики. Разбираем на Symfony 7.x (компонент symfony/event-dispatcher), но всё написанное справедливо и для 6.4 LTS.
Listener или Subscriber — и почему это не вкусовщина
В Symfony два способа подписаться на событие. EventListener — обычный сервис, привязанный к событию через тег kernel.event_listener или атрибут #[AsEventListener]. EventSubscriber — класс, реализующий EventSubscriberInterface, который сам объявляет, на что подписан, в методе getSubscribedEvents().
Разница не косметическая. У листенера конфигурация события живёт снаружи класса — в атрибуте или в services.yaml. У подписчика она внутри класса. Это меняет то, кто владеет знанием о подписке.
Listener удобен, когда одно и то же событие обрабатывают разные команды и вы хотите видеть подписки в конфиге. Subscriber удобен, когда один класс реагирует на несколько связанных событий и логика подписки — часть его ответственности.
Как выглядит и то, и другое
// Symfony 7.x — листенер через атрибут
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(event: KernelEvents::REQUEST, priority: 10)]
final class MaintenanceListener
{
public function __invoke(RequestEvent $event): void
{
// логика на входящий запрос
}
}
// Тот же смысл — подписчик
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class SecurityHeadersSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 10],
KernelEvents::RESPONSE => ['onResponse', -10],
];
}
public function onRequest(RequestEvent $event): void { /* ... */ }
public function onResponse(ResponseEvent $event): void { /* ... */ }
}
Если у вас включён autoconfigure: true (по умолчанию во Flex-приложении), оба варианта регистрируются автоматически — тег вешать руками не нужно. Именно из-за этой «магии» и начинаются проблемы: слушатель зарегистрирован, но ведёт себя не так, как ожидалось.
Ошибка 1. Приоритет: чем больше число, тем раньше
Самая частая путаница. В Symfony обработчик с бо́льшим приоритетом вызывается первым. Приоритет по умолчанию — 0. Отрицательные значения отодвигают обработчик в конец.
Классический промах: вы пишете слушатель на kernel.response, который должен добавить заголовок, и ставите ему высокий приоритет «чтобы точно сработал». А встроенный слушатель, который формирует тело ответа, отработал раньше и ваш заголовок затёрся. Для этапа RESPONSE логика обычно обратная: чем позже, тем ближе к финальной отдаче.
- Хотите отреагировать до роутинга и контроллера — высокий приоритет на
kernel.request(например,256и выше — этим числам соответствуют встроенные слушатели вроде RouterListener). - Хотите модифицировать готовый ответ — небольшой или отрицательный приоритет на
kernel.response.
Не угадывайте приоритеты встроенных слушателей — посмотрите их. Команда php bin/console debug:event-dispatcher kernel.request выводит все обработчики события в порядке вызова с их приоритетами. Это первое, что стоит запускать, когда порядок «поехал».
Ошибка 2. Слушатель срабатывает на подзапросах
Symfony обрабатывает не только основной HTTP-запрос. Форвардинг, ESI, рендер контроллера внутри шаблона (render(controller(...))) — всё это порождает подзапросы (sub-requests), и они тоже проходят через kernel.request и kernel.response.
Если ваш слушатель ставит заголовки безопасности, редиректит неавторизованных или пишет в лог «пользователь открыл страницу», он выстрелит на каждом подзапросе. Результат — дублирование логики и трудноуловимые баги.
use Symfony\Component\HttpKernel\Event\RequestEvent;
public function onRequest(RequestEvent $event): void
{
// работаем только с основным запросом
if (!$event->isMainRequest()) {
return;
}
// здесь безопасная бизнес-логика
}
Метод isMainRequest() — актуальное имя в Symfony 6 и 7. Старое isMasterRequest() удалено, в свежих проектах его нет. Правило простое: любой слушатель на kernel.request / kernel.response, который меняет поведение приложения, должен явно решить, работает он на подзапросах или нет.
Ошибка 3. Тяжёлые зависимости в конструкторе слушателя
Слушатели kernel.request инстанцируются на каждый запрос, даже если внутри вы сразу делаете return по условию. Если в конструктор такого слушателя влетает клиент к внешнему API, репозиторий, тянущий данные, или что-то с сетевым соединением, вы платите за это на всех запросах подряд.
Лечится ленивыми зависимостями. С Symfony и включённым autowiring проще всего внедрить сервис через Symfony\Contracts\Service\ServiceSubscriberInterface или ServiceLocator, чтобы тяжёлый сервис создавался только когда до него реально дошло дело. Точный синтаксис ленивого сервис-локатора смотрите в разделе Service Locators официальной документации — он менялся по мелочи между мажорными версиями, и лучше свериться, чем скопировать устаревшее.
Ошибка 4. Путаница между событиями ядра и Doctrine
События HttpKernel (kernel.request, kernel.controller, kernel.view, kernel.response, kernel.exception, kernel.terminate) идут через общий EventDispatcher. А события Doctrine ORM (prePersist, postUpdate и прочие) живут в собственном менеджере событий Doctrine и подписываются иначе.
Попытка поймать prePersist обычным EventSubscriber ядра ни к чему не приведёт — событие туда не приходит. Для Doctrine используются атрибуты вроде #[AsEntityListener] или подписка через её механизм. Это отдельная тема, здесь фиксируем только границу: не все «события Symfony» диспатчатся одним диспетчером.
Что чем обрабатывать
| Что нужно | Правильное событие / механизм |
|---|---|
| Ранняя проверка запроса, редирект неавторизованных | kernel.request, высокий приоритет |
| Подмена аргументов контроллера | kernel.controller / kernel.controller_arguments |
| Превратить не-Response в Response | kernel.view |
| Заголовки, кеш, финальная правка ответа | kernel.response, низкий приоритет |
| Единая обработка исключений | kernel.exception |
| Тяжёлая работа после отдачи ответа | kernel.terminate |
| Реакция на сохранение сущности | событие Doctrine, не ядра |
Ошибка 5. Бизнес-логика в kernel.response вместо kernel.terminate
Отправить письмо, дёрнуть внешний вебхук, записать аналитику — всё это не должно задерживать ответ пользователю. Если вы вешаете такую работу на kernel.response, клиент ждёт её завершения. Для действий, результат которых пользователю не нужен, есть kernel.terminate — оно бросается после того, как ответ уже отправлен браузеру.
Оговорка: kernel.terminate реально работает по назначению только при определённых условиях сервера (FastCGI с fastcgi_finish_request). На встроенном сервере разработки поведение может отличаться. Проверяйте, что заявленное «после ответа» действительно происходит после ответа, а не блокирует его.
Ошибка 6. Свои события без иммутабельности и без stopPropagation
Когда вы диспатчите собственное событие, помните две вещи. Первая: обработчики видят один и тот же объект события и могут его менять — это фича, на этом строится передача данных между слушателями. Вторая: любой слушатель может вызвать $event->stopPropagation(), и обработчики с меньшим приоритетом не получат управление.
use Symfony\Contracts\EventDispatcher\Event;
final class OrderPlacedEvent extends Event
{
public function __construct(
public readonly int $orderId,
public readonly string $customerEmail,
) {}
}
Диспатч через инъекцию Psr\EventDispatcher\EventDispatcherInterface:
$this->dispatcher->dispatch(new OrderPlacedEvent($order->getId(), $order->getEmail()));
Если один из подписчиков остановит распространение раньше времени, отправка письма или обновление статуса просто не случится — и в логах будет пусто. Не давайте слушателям право глушить событие без явной причины.
Когда что-то не срабатывает: чек-лист
- Запустите
php bin/console debug:event-dispatcher— если вашего слушателя нет в списке, он не зарегистрирован. Проверьтеautoconfigureи то, что класс объявлен как сервис. - Слушатель в списке, но не первый/не последний — сравните приоритеты со встроенными.
- Срабатывает лишний раз — проверьте
isMainRequest(). - Ловите Doctrine-событие обычным подписчиком — оно туда не придёт, смотрите механизм Doctrine.
- Событие бросается, но обработчик молчит — кто-то раньше вызвал
stopPropagation().
Что делать дальше
Сделайте debug:event-dispatcher рефлексом: перед тем как чинить порядок обработки, посмотрите фактический список и приоритеты, а не гадайте. Для новых слушателей на запросе и ответе сразу решайте вопрос подзапросов и не тащите тяжёлые зависимости в конструктор. Тяжёлую фоновую работу выносите либо в kernel.terminate, либо, если объём растёт, в Messenger — очередь надёжнее события, которое держит ответ. Инвалидация и кеширование самих обработчиков — отдельная тема, здесь мы её не трогали.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: Symfony — The EventDispatcher Component, Symfony — Built-in Symfony Events (HttpKernel)
Частые вопросы
У меня Symfony 6.4, а не 7 — атрибуты #[AsEventListener] и isMainRequest() работают?
Слушатель не срабатывает, хотя код точно правильный. С чего начать?
Чем EventSubscriber лучше EventListener и наоборот?
Почему моя логика в слушателе выполняется дважды на одной странице?
Можно ли ловить события Doctrine (prePersist и т.п.) обычным EventSubscriber?
Отправка письма в kernel.terminate иногда не задерживает ответ, а иногда задерживает. Почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.