Привет, Хабр!
Сегодня рассмотрим одну из самых сильных сторон Symfony — компонент EventDispatcher.
Если очень кратко, EventDispatcher позволяет создавать реактивную архитектуру: одни части приложения инициируют события, другие на них реагируют, не зная напрямую друг о друге.
В итоге проект получается гибким, расширяемым, легко тестируемым и не превращается в ужасный комок зависимостей.
Но чтобы использовать EventDispatcher правильно, мало просто вызвать dispatch() в коде. Нужно понимать:
как создавать свои события
как проектировать подписчиков
как управлять порядком вызовов
как останавливать цепочки событий
как тестировать их безопасно
как не наломать архитектуру плохим проектированием событий
И всё это мы сегодня коротко разберем.
Как устроен EventDispatcher
Когда мы диспатчим событие, происходит следующее:
Мы создаём объект события, т.е
Eventили его наследник.Вызываем
dispatch($event, $eventName).Внутри компонента по имени события ищутся все зарегистрированные слушатели.
Каждый слушатель вызывается с этим объектом события.
Если кто-то остановит распространение события
stopPropagation(), оставшиеся слушатели не вызываются.
Именно так EventDispatcher реализует паттерны Observer и Mediator: слушатели подписаны на события, но не знают о других участниках цепочки.
Событие всегда передаётся одним и тем же экземпляром объекта. Слушатели могут не только читать данные события, но и модифицировать их.
Пример работы
Начнём с самого простого — диспатчинг события и один слушатель. Допустим, есть сайт, и нужно реагировать на регистрацию пользователя.
Создадим EventDispatcher, слушателя и диспатчинг:
use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Contracts\EventDispatcher\Event; // Создаём диспетчер $dispatcher = new EventDispatcher(); // Регистрируем слушателя $dispatcher->addListener('user.registered', function (Event $event) { echo "Пользователь зареган."; }); // Где-то в коде диспатчим событие $dispatcher->dispatch(new Event(), 'user.registered');
Отправить 'пустое' событие — это только начало. В реальных задачах почти всегда нужно передавать вместе с событием данные. Посмотрим, как это делается
Как создавать события с полезной нагрузкой
В реальных приложениях почти всегда используется собственный класс события, а не голый Event.
Допустим, есть сущность User, и мы хотим передавать её внутри события.
Создадим свой Event-класс:
namespace App\Event; use Symfony\Contracts\EventDispatcher\Event; use App\Entity\User; class UserRegisteredEvent extends Event { public function __construct(private User $user) { } public function getUser(): User { return $this->user; } }
Теперь, при диспатче события можно передать полноценного пользователя:
use App\Event\UserRegisteredEvent; $user = new User('ivan.ivanov@example.com'); $event = new UserRegisteredEvent($user); $dispatcher->dispatch($event, UserRegisteredEvent::class);
И слушатель тоже получает доступ к объекту пользователя:
$dispatcher->addListener(UserRegisteredEvent::class, function (UserRegisteredEvent $event) { $user = $event->getUser(); echo "Привет, " . $user->getEmail(); });
Таким образом, мы диспатчим смысленное событие, а не абстрактное что-то случилось.
Как подписываться на события правильно: Listener vs Subscriber
В Symfony есть два способа подписываться на события:
Слушатель
Просто отдельная функция или метод, который регистрируется через addListener().
Используем тогда, когда нужна быстрая реакция на одно событие или сама по себе малая логика
Пример:
$dispatcher->addListener(UserRegisteredEvent::class, [new SendWelcomeEmail(), 'handle']);
Подписчик
Класс, который реализует EventSubscriberInterface и явно описывает:
на какие события он подписан
какими методами реагировать
Подписчик хорош когда: нужно подписаться на несколько события, задавать приоритеты и адекватно структурировать код. Его в основном и используют в проектах.
Пример подписчика:
namespace App\EventSubscriber; use App\Event\UserRegisteredEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class WelcomeEmailSubscriber implements EventSubscriberInterface { public static function getSubscribedEvents(): array { return [ UserRegisteredEvent::class => 'onUserRegistered', ]; } public function onUserRegistered(UserRegisteredEvent $event): void { $user = $event->getUser(); // отправляем email } }
И в конфигурации:
services: App\EventSubscriber\WelcomeEmailSubscriber: tags: - { name: 'kernel.event_subscriber' }
Подписчики — это более организованный способ работы, и в проектах всегда стоит их использовать.
Приоритеты вызова слушателей
Когда на одно событие подписаны несколько обработчиков, порядок их вызова имеет значение.
Symfony позволяет задавать приоритет слушателю. Больше приоритет = раньше вызов.
Пример в подписчике:
public static function getSubscribedEvents(): array { return [ UserRegisteredEvent::class => [ ['sendWelcomeEmail', 10], ['logUserRegistration', 5], ['notifyAdmin', -10], ], ]; }
Сначала отправится email, потом залогируется регистрация, а далее будет уведомление админа
Остановка цепочки событий
Иногда нужно прервать дальнейшее распространение события.
Для этого в классе события можно вызвать stopPropagation():
public function onUserRegistered(UserRegisteredEvent $event): void { if ($event->getUser()->isBanned()) { $event->stopPropagation(); } }
После вызова stopPropagation(), остальные слушатели для этого события уже не будут вызваны.
Как правильно тестировать события
Хорошая архитектура предполагает наличие тестов.
Самый простой способ протестировать работу события — это поймать диспатчинг события в юнит-тестах.
Пример теста:
use Symfony\Component\EventDispatcher\EventDispatcher; use PHPUnit\Framework\TestCase; use App\Event\UserRegisteredEvent; class UserEventTest extends TestCase { public function testUserRegisteredEventDispatched() { $dispatcher = new EventDispatcher(); $called = false; $dispatcher->addListener(UserRegisteredEvent::class, function (UserRegisteredEvent $event) use (&$called) { $called = true; }); $user = new User('test@example.com'); $dispatcher->dispatch(new UserRegisteredEvent($user), UserRegisteredEvent::class); $this->assertTrue($called); } }
Простой способ проверить, что событие действительно диспатчится и слушатели вызываются.
Типичные ошибки
Немного опыта:
Ошибка | Почему плохо |
|---|---|
Использовать голый Event без данных | Потом непонятно, что передавать и как обрабатывать |
Не использовать отдельные классы для событий | Логика становится нечитаемой и сложно расширяемой |
Игнорировать stopPropagation | Лишние слушатели продолжают работать, могут поломать процесс |
Смешивать бизнес-логику и отправку событий | Нельзя. Диспатчинг — это сигнал, не место для тяжёлой логики. |
Мини-проект
Допустим, нам нужно нужно смоделировать мини-проект интернет-магазина, который продаёт корм для котиков. После оформления заказа должны произойти следующие действия:
Отправить покупателю письмо с благодарностью.
Уведомить склад о необходимости собрать заказ.
Начислить бонусные баллы клиенту.
В случае проблем остановить цепочку событий.
Все эти действия должны быть реализованы через цепочку событий, чтобы система оставалась гибкой и расширяемой.
Архитектура событий
Для начала определимся с основной схемой: событие у нас будет называться OrderPlacedEvent, а реагировать на него будут сразу три слушателя —SendThankYouEmailListener (отправить письмо с благодарностью), NotifyWarehouseListener (уведомить склад о заказе) и AccrueBonusPointsListener (начислить клиенту бонусные баллы). Вся координация действий будет происходить через EventDispatcher.
Создание события
Создадим класс события OrderPlacedEvent, который будет содержать данные о заказе.
namespace App\Event; use Symfony\Contracts\EventDispatcher\Event; use App\Entity\Order; class OrderPlacedEvent extends Event { public function __construct(private Order $order) { } public function getOrder(): Order { return $this->order; } }
Событие несёт внутри себя объект заказа. Через метод getOrder() слушатели могут получить доступ к данным.
Сущность заказа
Для полноты картины сделаем упрощённую модель заказа:
namespace App\Entity; class Order { private int $id; private string $customerEmail; private bool $stockAvailable; public function __construct(int $id, string $customerEmail, bool $stockAvailable = true) { $this->id = $id; $this->customerEmail = $customerEmail; $this->stockAvailable = $stockAvailable; } public function getId(): int { return $this->id; } public function getCustomerEmail(): string { return $this->customerEmail; } public function isStockAvailable(): bool { return $this->stockAvailable; } }
Флаг stockAvailable показывает, есть ли товар на складе. Если его нет — событие должно быть остановлено, чтобы не слать письма и не начислять бонусы зря.
Реализация слушателей
Теперь создаём три слушателя.
Отправка письма благодарности
namespace App\Listener; use App\Event\OrderPlacedEvent; class SendThankYouEmailListener { public function __invoke(OrderPlacedEvent $event): void { $order = $event->getOrder(); $email = $order->getCustomerEmail(); // Имитируем отправку письма echo "Отправляем письмо на {$email}: Спасибо за заказ для вашего котика!\n"; } }
__invoke, чтобы слушателя можно было регистрировать компактно.
Уведомление склада
namespace App\Listener; use App\Event\OrderPlacedEvent; class NotifyWarehouseListener { public function __invoke(OrderPlacedEvent $event): void { $order = $event->getOrder(); if (!$order->isStockAvailable()) { echo "Ошибка: нет товара на складе. Останавливаем событие.\n"; $event->stopPropagation(); return; } echo "Уведомляем склад: собрать заказ №{$order->getId()}.\n"; } }
Если товара нет, то сразу вызываем stopPropagation(), чтобы прекратить дальнейшую обработку.
Начисление бонусов
namespace App\Listener; use App\Event\OrderPlacedEvent; class AccrueBonusPointsListener { public function __invoke(OrderPlacedEvent $event): void { $order = $event->getOrder(); echo "Начисляем бонусные баллы покупателю с email: {$order->getCustomerEmail()}.\n"; } }
Бонусы начисляются только если событие не было остановлено ранее.
Сборка всего вместе
Создадим диспетчер и зарегистрируем слушателей.
use Symfony\Component\EventDispatcher\EventDispatcher; use App\Event\OrderPlacedEvent; use App\Listener\SendThankYouEmailListener; use App\Listener\NotifyWarehouseListener; use App\Listener\AccrueBonusPointsListener; use App\Entity\Order; // Инициализируем диспетчер $dispatcher = new EventDispatcher(); // Регистрируем слушателей $dispatcher->addListener(OrderPlacedEvent::class, new NotifyWarehouseListener(), 20); $dispatcher->addListener(OrderPlacedEvent::class, new SendThankYouEmailListener(), 10); $dispatcher->addListener(OrderPlacedEvent::class, new AccrueBonusPointsListener(), 0); // Создаём заказ $order = new Order(101, 'catlover@example.com', true); // Диспатчим событие $dispatcher->dispatch(new OrderPlacedEvent($order));
Что увидим на выходе?
Если товар есть на складе:
Уведомляем склад: собрать заказ №101. Отправляем письмо на catlover@example.com: Спасибо за заказ для вашего котика! Начисляем бонусные баллы покупателю с email: catlover@example.com.
Если товара нет:
Ошибка: нет товара на складе. Останавливаем событие.
И всё — никакого письма и бонусов.
В итоге
EventDispatcher — мощная штука, если пользоваться с умом: чёткие события, отдельные классы, нормальные подписчики и порядок вызовов. А как у вас с событиями в проектах? Часто ими пользуетесь или пока мимо?
В заключение рекомендую посетить открытый урок по локализации текстов в Symfony, который пройдет 15 мая в 20:00 в OTUS. Разберём локализацию как статичных, так и динамических текстов, хранимых в базе данных, с использованием компонента symfony/translation. Узнаете, как эффективно работать с переводами и нестандартным маппингом.
Готовы проверить свои знания по Symfony? Пройдите вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.
