Привет, Хабр!

Сегодня рассмотрим одну из самых сильных сторон Symfony — компонент EventDispatcher.

Если очень кратко, EventDispatcher позволяет создавать реактивную архитектуру: одни части приложения инициируют события, другие на них реагируют, не зная напрямую друг о друге.

В итоге проект получается гибким, расширяемым, легко тестируемым и не превращается в ужасный комок зависимостей.

Но чтобы использовать EventDispatcher правильно, мало просто вызвать dispatch() в коде. Нужно понимать:

  • как создавать свои события

  • как проектировать подписчиков

  • как управлять порядком вызовов

  • как останавливать цепочки событий

  • как тестировать их безопасно

  • как не наломать архитектуру плохим проектированием событий

И всё это мы сегодня коротко разберем.

Как устроен EventDispatcher

Когда мы диспатчим событие, происходит следующее:

  1. Мы создаём объект события, т.е Event или его наследник.

  2. Вызываем dispatch($event, $eventName).

  3. Внутри компонента по имени события ищутся все зарегистрированные слушатели.

  4. Каждый слушатель вызывается с этим объектом события.

  5. Если кто-то остановит распространение события 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

Лишние слушатели продолжают работать, могут поломать процесс

Смешивать бизнес-логику и отправку событий

Нельзя. Диспатчинг — это сигнал, не место для тяжёлой логики.

Мини-проект

Допустим, нам нужно нужно смоделировать мини-проект интернет-магазина, который продаёт корм для котиков. После оформления заказа должны произойти следующие действия:

  1. Отправить покупателю письмо с благодарностью.

  2. Уведомить склад о необходимости собрать заказ.

  3. Начислить бонусные баллы клиенту.

  4. В случае проблем остановить цепочку событий.

Все эти действия должны быть реализованы через цепочку событий, чтобы система оставалась гибкой и расширяемой.

Архитектура событий

Для начала определимся с основной схемой: событие у нас будет называться 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? Пройдите вступительное тестирование и узнайте, насколько уверенно вы себя чувствуете в теме.

Комментарии (2)


  1. Dhwtj
    13.05.2025 13:18

    event driven architecture хорош для статической типизации, т.к. уменьшает зависимости

    а зачем это тута?


    1. Dhwtj
      13.05.2025 13:18

      То есть, я понял бы пользу на C#/Java/Go

      Но для динамически типизированных языков пользы от событийной архитектуры меньше, а затраты те же