Starting v5.3, Symfony's compiler pass to register event listeners and subscribers is no longer configurable, that is, we can no longer configure multiple event dispatchers, and we need to use Symfony's tags. I understand the need for simplification on Symfony's side, but none of this is practical for our projects that don't use the framework, only some of its components. Some of our applications cannot be updated because of this change. I decided it was a good opportunity to write my own Event Dispatcher, an implementation of PSR-14, that would come with a configurable compiler pass for symfony/dependency-injection.

The project is available on GitHub under olvlvl/event-dispatcher. What follows is an excerpt of the README available there.

Enters olvlvl/event-dispatcher

olvlvl/event-dispatcher provides an implementation of psr/event-dispatcher, which establishes a common mechanism for event-based extension and collaboration.

Package highlights

  • Supports Event inheritance, including interfaces.
  • Supports stoppable Events.
  • Provides a collection of composable Event Dispatchers and Listener Providers.
  • Introduces Mutable Listener Providers.
  • Provides a compiler pass for symfony/dependency-injection.

Installation

composer require olvlvl/event-dispatcher

Event Dispatcher

An Event Dispatcher is a service object that is given an Event object by an Emitter. The Dispatcher is responsible for ensuring that the Event is passed to all relevant Listeners, but MUST defer determining the responsible listeners to a Listener Provider.

Basic Event Dispatcher

BasicEventDispatcher is a basic implementation of an Event Dispatcher, that complies with the requirements for Dispatchers.

<?php

use olvlvl\EventDispatcher\BasicEventDispatcher;

/* @var Psr\EventDispatcher\ListenerProviderInterface $listenerProvider */
/* @var object $event */

$dispatcher = new BasicEventDispatcher($listenerProvider);
$dispatcher->dispatch($event);

Buffered Event Dispatcher

In some situations, it can be desired to defer the dispatching of events. For instance, an application that's presenting an API to create recipes, and needs to index created recipes and run additional time-consuming calculations, would want to defer dispatching the events, to reply as soon as possible to the user.

BufferedEventDispatcher decorates an Event Dispatcher and buffers events that can be dispatched at a later time. The user can provide a discriminator that decides whether an event should be buffered or dispatched immediately.

Careful using this type of Dispatcher! Because event dispatching is delayed, it will cause issues for users that expect Events to be modified.

Note: In accordance with Dispatchers requirements, stopped Events are discarded and not be buffered.

<?php

use olvlvl\EventDispatcher\BufferedEventDispatcher;

/* @var Psr\EventDispatcher\EventDispatcherInterface $decorated */
/* @var object $eventA */
/* @var SampleInterface $eventB */
/* @var object $eventC */

$dispatcher = new BufferedEventDispatcher(
    $decorated,
    // An optional discriminator
    function (object $event): bool {
        return !$event instanceof SampleInterface;
    }
);

$dispatcher->dispatch($eventA);
// $eventB is dispatched immediately
$dispatcher->dispatch($eventB);
$dispatcher->dispatch($eventC);

// ... Some code here, maybe reply to a request.

$dispatchedEvents = $dispatcher->dispatchBufferedEvents();

Listener Provider

A Listener Provider is responsible for determining what Listeners are relevant to and should be called for a given Event. olvlvl/event-dispatcher provides a few Listener Provider implementations, that comply with the requirements and recommendations for Listener Providers.

Listener Provider with a map

ListenerProviderWithMap is a Listener Provider that uses an array of Event/Listeners pairs.

<?php

use olvlvl\EventDispatcher\ListenerProviderWithMap;

/* @var callable $callableA */
/* @var callable $callableB */
/* @var callable $callableC */

$provider = new ListenerProviderWithMap([

    MyEventA::class => [ $callableA ],
    MyEventInterfaceA::class => [ $callableB, $callableC ],

]);

Listener Provider with a container

ListenerProviderWithContainer is a Listener Provider that uses an array of Event/service id pairs and retrieves Listeners from a PSR container.

Note: olvlvl/event-dispatcher provides a compiler pass for symfony/dependency-injection that is very handy to collect Event Listeners and build Listener Providers.

<?php

use olvlvl\EventDispatcher\ListenerProviderWithContainer;

/* @var Psr\Container\ContainerInterface $container */

$provider = new ListenerProviderWithContainer([

    SampleEventA::class => [ 'serviceA' ],
    SampleEventInterfaceA::class => [ 'serviceA', 'serviceB' ],

], $container);

Mutable Listener Provider

MutableListenerProvider is a mutable Listener Provider, that is, listeners can be added and removed. To this effect, the Provider has no constructor arguments so that any Listener it contains can also be removed.

The Listener Provider implements MutableListenerProviderInterface, which extends ListenerProviderInterface. The interface can be used to distinguish a mutable Listener Provider from a non-mutable one.

<?php

use olvlvl\EventDispatcher\MutableListenerProviderInterface;

/* @var Psr\EventDispatcher\ListenerProviderInterface $provider */

if ($provider instanceof MutableListenerProviderInterface) {
    // ... we can add or remove Listeners.
}

A Listener for an Event can be added to the end of the list with the method appendListenerForEvent(), or to the beginning of the list with the method prependListenerForEvent(). Both methods return a callable that can be used to remove the Listener.

Note: A LogicException is thrown if a Listener is added twice for an Event type. The call is not failing silently because a Listener can have very different and unpredictable outcomes whether it was prepended or appended.

The following example demonstrates how a Listener can be appended for an Event to a mutable Listener Provider. In the example, the "remove" callable is used by the Listener to remove itself once it has been called. This is how one would set up a "once" Listener. Of course, this is just an example of application.

<?php

use olvlvl\EventDispatcher\MutableListenerProvider;

$provider = new MutableListenerProvider();
$remove = $provider->appendListenerForEvent(
    SampleEvent::class,
    function (SampleEvent $event) use (&$remove): void {
        // This is how one can implement a "once" listener.
        // The listener is removed when it's called.
        $remove();
        // ... do something with the event here.
    }
);

Listener Provider Chain

With ListenerProviderChain, multiple Listener Providers can be combined to act like one. They are called in succession to provide Listeners for an Event.

The chain is mutable, Listener Providers can be added to the end of the chain using the appendListenerProviders() method, or to the beginning of the chain using the prependListenerProviders() method.

Note: Since ListenerProviderChain is a Provider Listener like any other, creating a chain of chains is a possibility.

The following example demonstrates how to create a chain of Listener Providers, and modify that chain by appending and prepending others.

<?php

use olvlvl\EventDispatcher\ListenerProviderChain;

// Compose a Listener Provider from a number of Listener Providers.

/* @var $providerA olvlvl\EventDispatcher\MutableListenerProvider */
/* @var $providerB olvlvl\EventDispatcher\ListenerProviderWithMap */
/* @var $providerC olvlvl\EventDispatcher\ListenerProviderWithContainer */

$provider = new ListenerProviderChain([ $providerA, $providerB, $providerC ]);

// Listener Providers can be added to the end of the chain.

/* @var $providerD Psr\EventDispatcher\ListenerProviderInterface */
/* @var $providerE Psr\EventDispatcher\ListenerProviderInterface */

$provider->appendListenerProviders($providerD, $providerE);

// Listener Providers can be added to the beginning of the chain.

/* @var $providerF Psr\EventDispatcher\ListenerProviderInterface */
/* @var $providerG Psr\EventDispatcher\ListenerProviderInterface */

$provider->prependListenerProviders($providerF, $providerG);

// Obtain the Listeners for an event

/* @var object $event */

foreach ($provider->getListenersForEvent($event) as $listener) {
    // ... do something with the listeners
}

Listener Provider Filter

ListenerProviderFilter decorates a Listener Provider to filter Listeners according to a user specified discriminator. The filter can be used to implement some form of access control so that certain Listeners will only be called if the current user has a certain permission.

The following example demonstrates how the filter can be used to discard $listener_1 for SampleEventA and $listener_2 for SampleEventC.

<?php

use olvlvl\EventDispatcher\ListenerProviderFilter;
use olvlvl\EventDispatcher\ListenerProviderWithMap;

/* @var callable $listener_1 */
/* @var callable $listener_2 */

$provider = new ListenerProviderFilter(
    new ListenerProviderWithMap([
        SampleEventA::class => [ $listener_1, $listener_2 ],
        SampleEventC::class => [ $listener_1, $listener_2 ],
    ]),
    function (object $event, callable $listener) use ($listener_1, $listener_2): bool {
        if ($event instanceof SampleEventA && $listener === $listener_1) {
            return false;
        }

        if ($event instanceof SampleEventC && $listener === $listener_2) {
            return false;
        }

        return true;
    }
);

Compiler pass for symfony/dependency-injection

The package provides a compiler pass for symfony/dependency-injection that builds one or many Listener Providers automatically.

Basically, the compiler pass searches for the tagged services, collect their Event Listeners, creates a mapping with their events, and overwrite a few attributes to complete the definition of the service.

Adding the compiler pass

<?php

use Symfony\Component\DependencyInjection\ContainerBuilder;
use olvlvl\EventDispatcher\Symfony\ListenerProviderPass;

$container = new ContainerBuilder();
$container->addCompilerPass(new ListenerProviderPass());

By default, the tag used to identify the Listener Providers to build is listener_provider, but it can be configured:

<?php

use Symfony\Component\DependencyInjection\ContainerBuilder;
use olvlvl\EventDispatcher\Symfony\ListenerProviderPass;

$container = new ContainerBuilder();
$container->addCompilerPass(new ListenerProviderPass('my_listener_provider_tag'));

Defining the services

The following example uses the PSR interface as service identifier, but a name such as my_listener_provider can be used just the same, as we'll see later when building multiple Listener Providers. Also, it is not required to specify the synthetic attribute, but it is recommended to indicate to fellow developers that the service definition is a stub.

Note: To complete the service definition, the compiler pass overwrites the attributes synthetic, class, and arguments, but leaves intact any other attribute.

services:
  Psr\EventDispatcher\ListenerProviderInterface:
    synthetic: true
    tags: [ listener_provider ]

By default, the tag for the Listener services is event_listener but it can be configured, which is required when building multiple Listener Providers.

services:
  Psr\EventDispatcher\ListenerProviderInterface:
    synthetic: true
    tags:
    - { name: listener_provider, listener_tag: event_listener }

The following example demonstrates own Listener services are attached to a Listener Provider. They are tagged with event_listener, which is the default tag. A listener can listen to multiple events, as is demonstrated by ListenerC.

services:
   Psr\EventDispatcher\ListenerProviderInterface:
     synthetic: true
     tags: [ listener_provider ]

   Acme\MyApp\ListenerA:
     tags:
     - { name: event_listener, event: Acme\MyApp\EventA }

   Acme\MyApp\ListenerB:
     tags:
     - { name: event_listener, event: Acme\MyApp\EventB }

   # ListenerC listens to EventA and EventC
   Acme\MyApp\ListenerC:
     tags:
     - { name: event_listener, event: Acme\MyApp\EventA }
     - { name: event_listener, event: Acme\MyApp\EventC }

Building multiple Listener Providers

It is possible to build multiple Listener Providers, you just need to specify which Listener tag to use for each of them:

services:
  listener_provider_a:
    class: Psr\EventDispatcher\ListenerProviderInterface
    synthetic: true
    tags:
    - { name: listener_provider, listener_tag: event_listener_for_a }

  listener_provider_b:
    class: Psr\EventDispatcher\ListenerProviderInterface
    synthetic: true
    tags:
    - { name: listener_provider, listener_tag: event_listener_for_b }

  Acme\MyApp\ListenerA1:
    tags:
    - { name: event_listener_for_a, event: Acme\MyApp\EventA }

  Acme\MyApp\ListenerA2:
    tags:
    - { name: event_listener_for_a, event: Acme\MyApp\EventA }

  Acme\MyApp\ListenerB:
    tags:
    - { name: event_listener_for_b, event: Acme\MyApp\EventB }

  # ListenerM is used by both Providers A and B,
  # but it will only receive EventC from Provider B
  Acme\MyApp\ListenerM:
    tags:
    - { name: event_listener_for_a, event: Acme\MyApp\EventA }
    - { name: event_listener_for_b, event: Acme\MyApp\EventA }
    - { name: event_listener_for_b, event: Acme\MyApp\EventC }

Specifying priorities

If Listeners are spread over multiple files, or it's not practical to keep them ordered, priorities for each Event/Listener pair can be defined instead.

Valid priorities are integers, positive or negative, or one of the special values first and last. With these special values, the Event/Listener pair is placed first or last, no matter the other priorities. Multiple Event/Listeners pairs can use these special values, in which case, the effect stacks. In the case of equal priorities, the definition order is preserved.

Note: If not specified, the priority defaults to 0.

The following example demonstrates how the priority attribute can be used to specify the order of Listeners. The final order will be as follows:

  • For SampleEventA: listener_e, listener_d, listener_c, listener_a, listener_b.
  • For SampleEventB: listener_d, listener_b.
services:
  Psr\EventDispatcher\ListenerProviderInterface:
    synthetic: true
    public: true
    tags: [ listener_provider ]

  listener_a:
    class: SampleListener
    tags:
    - name: event_listener
      event: SampleEventA
      priority: -10

  listener_b:
    class: SampleListener
    tags:
    - name: event_listener
      event: SampleEventA
      priority: last
    - name: event_listener
      event: SampleEventB

  listener_c:
    class: SampleListener
    tags:
    - name: event_listener
      event: SampleEventA

  listener_d:
    class: SampleListener
    tags:
    - name: event_listener
      event: SampleEventA
      priority: first
    - name: event_listener
      event: SampleEventB
      priority: 10

  listener_e:
    class: SampleListener
    tags:
    - name: event_listener
      event: SampleEventA
      priority: first

Conclusions

With PSR-14 and olvlvl/event-dispatcher we have a simpler codebase to maintain and we can configure the compiler pass to fit our needs.