Decoupling from Symfony Event Dispatcher

We all know the Symfony Event Dispatcher. But just using the event dispatcher makes us coupled to the framework. Our framework provides us with the basic components to create our self an awesome system. But there is always a catch, we might go fast, without thinking. As a result, we are risking to be too deeply coupled with the framework.

Ok, this might be overreacting. I’m not going to tell you here that you should not use framework components, or to be coupled to components and bundles. But you should think about it. In some situations, this coupling is not an issue. In some situations, it might be.

Remember, you can use the ideas from Ports and Adapters to create a port (interface) and an adapter. With this, you can still use components from our framework, or any external bundles. And with this, you decide how to use it.

Are you still reading? Well, that means I will show you how you can do this with the Symfony Event Dispatcher. And in the end, you can decide for yourself if you are going to get yourself decoupled from it. And worst case, you will know how.

Event Dispatcher Interfaces

If you want to decouple from the framework, then imagine that you don’t have any framework.

Simple right? Well, let’s start with this.

I have an Event. The name of the event is the name of the class.

<?php
namespace App\Infrastructure\EventDispatcher;

interface Event
{
}

I want an Event Dispatcher that can dispatch this Event. And the ability to add subscribers to the event dispatcher.

<?php
namespace App\Infrastructure\EventDispatcher;

interface EventDispatcherInterface
{
    public function dispatch(Event $event): void;

    public function addSubscriber(EventSubscriberInterface $subscriber): void;
}

And then I want to create multiple Event Subscribers which can subscribe to multiple events.

These Event Subscribers can then be added to our event dispatcher. The event dispatcher itself should be able to read an array of subscribed events without the need to instantiate an object.

<?php
namespace App\Infrastructure\EventDispatcher;

interface EventSubscriberInterface
{
    public static function getSubscribedEvents(): array;
}

That was easy right? You can do these steps yourself.  Decide how your interfaces should behave, and what functionalities you require.

Do you want to work with listeners except for subscribers? Well, you can! Do you want to have both options? Well, you can! It’s all up to you. You decide, not your framework.

You create the contracts of how your systems should use your Event Dispatcher.

Symfony Event Dispatcher Adapter

Now we need to create the adapters and connect with the Symfony EventDispatcher.
The first thing we do, create an adapter to implement our EventDispatcherInterface.

<?php
namespace App\Infrastructure\EventDispatcher\Adapter;

use App\Infrastructure\EventDispatcher\Event;
use App\Infrastructure\EventDispatcher\EventDispatcherInterface;

class SymfonyEventDispatcher implements EventDispatcherInterface
{
    public function dispatch(Event $event): void
    {
        // TODO: Implement dispatch() method.
    }

    public function addSubscriber(EventSubscriberInterface $subscriber): void
    {
        // TODO: Implement addSubscriber() method.
    }
}

So what now? Composition… Because that’s what creating adapter is.

The adapter transform an interface into another with composition.

Thus we inject the Symfony EventDispatcherInterface.

/**
 * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
 */
private $eventDispatcher;

public function __construct(\Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher)
{
    $this->eventDispatcher = $eventDispatcher;
}

First things first. We need to add subscribers to the event dispatcher.

But we should know about the use cases first.

public static function getSubscribedEvents(): array
{
    return [
        SalesOrderCreatedEvent::class => ['sendEmail'],
        SalesOrderCreatedEvent::class => [
            ['sendEmail', 10],
            ['sendAnotherEmail', 20]
        ],
    ];
}

We could have an event that is subscribed to one method. Or we can subscribe to an event to multiple methods. Both cases should work. You should create unit tests to validate all your use cases.  But this is out of the scope of this article.

So let’s implement the addSubscriber method.

public function addSubscriber(EventSubscriberInterface $subscriber): void
{
    foreach ($subscriber::getSubscribedEvents() as $eventName => $params) {
        if (\is_string($params[0])) {
            $this->addListener($eventName, [$subscriber, $params[0]], $params[1] ?? 0);
        } else {
            foreach ($params as $listener) {
                $this->addListener($eventName, [$subscriber, $listener[0]], $listener[1] ?? 0);
            }
        }
    }
}

public function addListener($eventName, $listener, $priority = 0): void
{
    $this->eventDispatcher->addListener($eventName, $listener, $priority);
}

As you can see we took care of both cases and added listeners for all the subscribed events to the event dispatcher. So when we add subscribers to our event dispatcher, they will be handled by our frameworks event dispatcher.

But now how do we dispatch these events to the Symfony event dispatcher?

public function dispatch(Event $event): void
{
    $listeners = $this->eventDispatcher->getListeners(\get_class($event));

    foreach ($listeners as $listener) {
        $listener($event);
    }
}

Well, we get all the listeners for the event we dispatch, and then execute those listeners.

As you can see, its pretty simple to create an adapter for the event dispatcher. You can still subscribe to events that are dispatched through the Symfony Event dispatcher (kernel events or events from other bundles). So there is no real downside, no functionality is lost. But you gain control and decrease technical debt.

Registering Subscribers

We still have one issue. As you might know. EventSubscribers in Symfony are auto-wired and automatically subscribed to the event dispatcher.  Event subscribers from our interface, are not auto-wired and automatically added as subscribers. But we are going to change that.

We can create our own compiler pass. This allows us to manipulate services from the service container. We are going to find our Event Dispatcher from the container, inject the Symfony Event Dispatcher in it. Then we find all Event Subscribers which are tagged with infrastructure.event_subscriber and connect them to our Event Dispatcher.

But first, we need to tag all classes with our EventSubscriberInterface.

We could manually tag every single EventSubscriber, but this is tedious. Instead, we register another autoconfiguration in kernel.php.

protected function build(ContainerBuilder $container): void
{
    $container->registerForAutoconfiguration(\App\Infrastructure\EventDispatcher\EventSubscriberInterface::class)->addTag('infrastructure.event_subscriber');
}

What this does is find all the EventSubscriberInterface and add a tag. We can then find all services with this tag in our compiler pass to add the subscribed events to our Event Dispatcher.

<?php
namespace App\Infrastructure\EventDispatcher\DependencyInjection;

use App\Infrastructure\EventDispatcher\Adapter\SymfonyEventDispatcher;
use App\Infrastructure\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;

class RegisterSubscribers implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $eventDispatcher = $container->get('event_dispatcher');
        $extractingDispatcher = new ExtractingEventDispatcher($eventDispatcher);

        $definition = $container->findDefinition(
            \App\Infrastructure\EventDispatcher\EventDispatcherInterface::class
        );

        foreach ($container->findTaggedServiceIds('infrastructure.event_subscriber', true) as $id => $attributes) {
            $def = $container->getDefinition($id);

            $class = $def->getClass();

            if (!$r = $container->getReflectionClass($class)) {
                throw new InvalidArgumentException(sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id));
            }
            if (!$r->isSubclassOf(EventSubscriberInterface::class)) {
                throw new InvalidArgumentException(sprintf('Service "%s" must implement interface "%s".', $id, EventSubscriberInterface::class));
            }
            $class = $r->name;

            ExtractingEventDispatcher::$subscriber = $class;
            $extractingDispatcher->addSubscriber($extractingDispatcher);
            foreach ($extractingDispatcher->listeners as $args) {
                $args[1] = array(new ServiceClosureArgument(new Reference($id)), $args[1]);
                $definition->addMethodCall('addListener', $args);
            }
            $extractingDispatcher->listeners = array();
        }
    }
}

/**
 * @internal
 */
class ExtractingEventDispatcher extends SymfonyEventDispatcher implements EventSubscriberInterface
{
    public $listeners = array();

    public static $subscriber;

    public function addListener($eventName, $listener, $priority = 0): void
    {
        $this->listeners[] = array($eventName, $listener[1], $priority);
    }

    public static function getSubscribedEvents(): array
    {
        $callback = array(self::$subscriber, 'getSubscribedEvents');

        return $callback();
    }
}

A simple way to explain this. We will find all tagged services. We create an ExtractingEventDispatcher that also implement or EventSubscriberInterface. We then use this class to extract the subscribers from our tagged services and add these to the listeners list.

And then we also need to add the compiler pass in kernel.php.

protected function build(ContainerBuilder $container): void
{
    $container->registerForAutoconfiguration(\App\Infrastructure\EventDispatcher\EventSubscriberInterface::class)->addTag('infrastructure.event_subscriber');

    $container->addCompilerPass(new RegisterSubscribers());
}

And we are done.

Conclusion

I showed you how to decouple from the Symfony EventDispatcher. In some situations, this might seem like a waste of time, and unnecessary. In some cases, this might be a must. But at least now you know how to deal with the situation. You can see that being decoupled has no real disadvantages, only advantages.

So taking the time to think before you start your project is a good idea. Which components am I going to use? Should I create some interfaces to decouple? Or make it easier for the developers to use these components? In my opinion, we should strive to have our core domain and application be complete without framework code. But this might be unrealistic or unnecessary in some situations.

Remember, it will only cost you one thing; a bit of time. But it might actually save you time in the long run.

As always, remember! Think before you code.