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.