События и подписчики в Symfony: как сделать правильно
Когда логика после регистрации пользователя обрастает уведомлениями, начислением бонусов и логами, контроллер превращается в кашу. Событийная модель Symfony разгружает его — если знать разницу между слушателем и подписчиком и не наступить на порядок вызовов.

Типичная ситуация: контроллер регистрации сначала создавал пользователя. Потом к нему добавили отправку письма. Потом начисление приветственного бонуса. Потом запись в лог аудита. Через полгода метод register() — это 80 строк, которые невозможно тестировать по частям, а новый разработчик боится туда лезть. Symfony предлагает вынести побочные действия в отдельные обработчики, которые реагируют на событие «пользователь зарегистрирован». Разберём, как это сделать так, чтобы не пришлось переделывать через месяц.
Материал для Symfony 6.4 и 7.x — API компонента EventDispatcher в этих версиях стабилен. Если у вас 5.x, конфигурация автовайринга та же, но проверьте синтаксис атрибутов PHP 8.
Два способа подписаться на событие
В Symfony есть два механизма реагировать на события, и путаница между ними — источник большинства проблем у новичков.
- Listener (слушатель) — класс, который реагирует на одно конкретное событие. Связь между классом и событием описывается снаружи: тегом в конфигурации или атрибутом
#[AsEventListener]. - Subscriber (подписчик) — класс, который сам объявляет, на какие события подписан, через статический метод
getSubscribedEvents(). Один подписчик может обрабатывать несколько событий сразу.
Функционально они делают одно и то же — вызываются диспетчером. Разница в том, где хранится знание о подписке.
Когда что выбирать
Правило простое и проверяется одним вопросом: где должно жить знание о том, на какое событие вешается обработчик.
| Критерий | Listener | Subscriber |
|---|---|---|
| Сколько событий обрабатывает | Обычно одно | Несколько, связанных логически |
| Где описана подписка | В конфиге или атрибуте класса | Внутри самого класса |
| Приоритет виден в | Конфигурации | Коде класса |
| Удобно, когда | Один обработчик на событие фреймворка (kernel.request и т.п.) | Своя доменная логика вокруг набора событий |
Для доменных событий вашего приложения почти всегда берите Subscriber: подписка живёт рядом с логикой, и при чтении класса сразу видно, на что он реагирует и с каким приоритетом. Listener хорош для точечной привязки к событиям ядра.
Своё событие: не наследуйте лишнего
Первая ошибка новичка — наследовать событие от Symfony\Contracts\EventDispatcher\Event без нужды. Этот базовый класс нужен только ради stopPropagation() — остановки цепочки обработчиков. Если останавливать распространение не планируете, можно обойтись обычным классом-значением (POPO). Но большинство проектов наследуют от Event «на всякий случай» — это допустимо и не вредит.
<?php
// src/Event/UserRegisteredEvent.php
namespace App\Event;
use App\Entity\User;
use Symfony\Contracts\EventDispatcher\Event;
final class UserRegisteredEvent extends Event
{
// Событие несёт только данные, никакой логики
public function __construct(
private readonly User $user,
) {
}
public function getUser(): User
{
return $this->user;
}
}Событие — это переносчик данных, а не место для методов вроде sendEmail(). Всю работу делают обработчики.
Диспатч события из контроллера
Контроллер после сохранения пользователя только сообщает системе: событие произошло. Кто на него отреагирует — контроллер знать не должен.
<?php
// src/Controller/RegistrationController.php
namespace App\Controller;
use App\Entity\User;
use App\Event\UserRegisteredEvent;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
class RegistrationController extends AbstractController
{
public function register(
EntityManagerInterface $entityManager,
EventDispatcherInterface $dispatcher,
): Response {
$user = new User();
// ...заполнение полей из формы опущено
$entityManager->persist($user);
$entityManager->flush();
// Сообщаем системе о факте — детали реакции нас не касаются
$dispatcher->dispatch(new UserRegisteredEvent($user));
return $this->redirectToRoute('app_home');
}
}Тип Psr\EventDispatcher\EventDispatcherInterface — это PSR-14. Symfony также предоставляет свой Symfony\Component\EventDispatcher\EventDispatcherInterface с более широким API (например, установка имени события). Для доменных событий, где имя определяется классом, PSR-14 достаточно.
Подписчик, который реагирует
Вот подписчик, отправляющий приветственное письмо. Автовайринг Symfony зарегистрирует его автоматически — если у вас включена стандартная конфигурация services.yaml с autoconfigure: true.
<?php
// src/EventSubscriber/UserRegisteredSubscriber.php
namespace App\EventSubscriber;
use App\Event\UserRegisteredEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class UserRegisteredSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly MailerInterface $mailer,
) {
}
public static function getSubscribedEvents(): array
{
// Ключ — класс события, значение — метод и приоритет
return [
UserRegisteredEvent::class => ['onUserRegistered', 10],
];
}
public function onUserRegistered(UserRegisteredEvent $event): void
{
$user = $event->getUser();
$email = (new Email())
->to($user->getEmail())
->subject('Добро пожаловать')
->text('Спасибо за регистрацию.');
$this->mailer->send($email);
}
}Число 10 в getSubscribedEvents() — это приоритет. Чем больше число, тем раньше вызовется обработчик. Обработчики с одинаковым приоритетом вызываются в порядке регистрации, но полагаться на этот порядок — плохая идея.
Атрибут как альтернатива
Если хочется точечный listener без метода getSubscribedEvents(), начиная с Symfony 6.x есть атрибут:
<?php
namespace App\EventListener;
use App\Event\UserRegisteredEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: UserRegisteredEvent::class, priority: 10)]
final class SendWelcomeEmailListener
{
public function __invoke(UserRegisteredEvent $event): void
{
// логика
}
}Атрибут можно вешать и на класс (тогда вызывается __invoke), и на отдельный метод. Проверьте наличие AsEventListener в вашей версии в документации компонента — в ранних 6.x он появился не сразу.
Где это ломается
События выглядят элегантно, пока не столкнёшься с их подводными камнями.
- Синхронность. По умолчанию
dispatch()выполняет всех подписчиков синхронно, в том же запросе. Если внутри обработчика отправляется письмо через медленный SMTP, пользователь ждёт ответа контроллера всё это время. Тяжёлые задачи выносите в Messenger — событие только ставит сообщение в очередь. - Флаш до диспатча. Если вызвать
dispatch()доflush(), у сущности может не быть ID, а подписчик, которому ID нужен, получитnull. Диспатчьте после того, как данные гарантированно в базе. - Исключение в подписчике. Если обработчик бросит исключение, оно всплывёт в контроллер и прервёт запрос — а пользователь уже создан в базе. Для некритичных побочных действий (лог, аналитика) оборачивайте тело обработчика в try/catch или уводите в очередь.
- Скрытая связанность. Через полгода трудно понять, что вообще происходит при регистрации: обработчики разбросаны по разным файлам. Спасает команда
php bin/console debug:event-dispatcher— она показывает все зарегистрированные слушатели с приоритетами.
Проверить, что подписка работает
Не гадайте, зарегистрировался ли обработчик. Выполните в терминале проекта:
php bin/console debug:event-dispatcher "App\Event\UserRegisteredEvent"Команда выведет список обработчиков с их приоритетами в порядке вызова. Если ваш подписчик там не появился — проверьте, что класс реализует EventSubscriberInterface и что автоконфигурация в services.yaml не отключена для этой директории.
Что делать дальше
Когда базовая схема заработала, посмотрите в сторону двух вещей. Первое — symfony/messenger: он позволяет обрабатывать событие асинхронно, чтобы контроллер не ждал отправки письма. Второе — встроенные события Doctrine и ядра (kernel.request, kernel.exception): те же принципы, только событие диспатчит не ваш код, а фреймворк. Инвалидация и порядок между несколькими подписчиками одного события — отдельная тема, здесь мы разобрали только базовую механику. В проде смотрите на время ответа контроллеров, где идёт диспатч: рост latency — первый признак, что тяжёлый обработчик пора уводить в очередь.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: The EventDispatcher Component — Symfony Docs, Events and Event Listeners — Symfony Docs
Частые вопросы
Listener или Subscriber — что использовать по умолчанию?
У меня Symfony 5.4, атрибут AsEventListener не работает
Подписчик не срабатывает, хотя код вроде правильный
Как сделать обработку события асинхронной, чтобы не тормозить запрос?
Что будет, если подписчик бросит исключение?
Можно ли одним подписчиком обработать несколько разных событий?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.