In a previous post I talked about Signals, Slots and Hooks in TYPO3 as ways to intercept third party code. Whenever a piece of code provides a hook or a signal it is possible to register some sort of listener and alter, enhance, add to or simply observe (e.g. log) the event that happened. There have been many different concepts and approaches for this kind of code extendability (we see two different concepts in TYPO3 alone) and therefore the PHP Framework Interop Group (php-fig.org) has been summoned once again to find a standardized way of handling this.

What the people involved in this task came up with, was released under the PHP Standard Recommendation 14 (PSR-14) and is called "Event Dispatcher". As you should be aware of by now TYPO3 loves to implement PSRs to take advantage of smart solutions that are used in the same fashion by many other PHP projects as well. This makes a lot of sense and also opens up the possibility to integrate already battle proof packages and concepts so that TYPO3 can focus on it's main concern: being a outstanding content management system.

The latest examples of PSR integrations are Dependecy Injection (PSR-11) and Middlewares (PSR-15).

Next in line is PSR-14, which was already integrated into the TYPO3 core (Patch, Feature RST) by Benni Mack, who by the way was part of the PSR-14 working group (to assure the standard would actual fulfill the requirements of TYPO3 in the end ;)). So shoutout to Benni for being himself!

So let's have a look now on what PSR-14 is, how it is integrated in TYPO3, how to use it in the future, as well as how to migrate from signals and hooks that will most likely disappear one day.

Ready when you are.

Top

PSR-14 explained

To cite from the "Goal" section of the PSR-14 documentation:

Having common interfaces for dispatching and handling events allows developers to create libraries that can interact with many frameworks and other libraries in a common fashion.

PSR-14 consists of two mandatory and one optional interface.

First there is the mandatory EventDispatcherInterface:

namespace Psr\EventDispatcher;

/**
 * Defines a dispatcher for events.
 */
interface EventDispatcherInterface
{
    /**
     * Provide all relevant listeners with an event to process.
     *
     * @param object $event
     *   The object to process.
     *
     * @return object
     *   The Event that was passed, now modified by listeners.
     */
    public function dispatch(object $event);
}

Next is the also mandatory ListenerProviderInterface:

namespace Psr\EventDispatcher;

/**
 * Mapper from an event to the listeners that are applicable to that event.
 */
interface ListenerProviderInterface
{
    /**
     * @param object $event
     *   An event for which to return the relevant listeners.
     * @return iterable[callable]
     *   An iterable (array, iterator, or generator) of callables.  Each
     *   callable MUST be type-compatible with $event.
     */
    public function getListenersForEvent(object $event) : iterable;
}

Listener should always return void. If a listener returns anything else, the returned value is to be discarded by the dispatcher. The only way to add or change data is through the API of the dispatched Event object.

And finally there is an Interface for Events which can be marked as "done" so that no further listener is consulted. This interface is optional in the sense that only events that are considered stoppable have to implement this.

namespace Psr\EventDispatcher;

/**
 * An Event whose processing may be interrupted when the event has been handled.
 *
 * A Dispatcher implementation MUST check to determine if an Event
 * is marked as stopped after each listener is called.  If it is then it should
 * return immediately without calling any further Listeners.
 */
interface StoppableEventInterface
{
    /**
     * Is propagation stopped?
     *
     * This will typically only be used by the Dispatcher to determine if the
     * previous listener halted propagation.
     *
     * @return bool
     *   True if the Event is complete and no further listeners should be called.
     *   False to continue calling listeners.
     */
    public function isPropagationStopped() : bool;
}

The idea is that any code can dispatch an Event by calling $event = $eventDispatcher->dispatch($event). There is no interface for the Event itself (except when it's stoppable, then the interface from above is to be used) because an Event is a very specific object and depends on the context. It could be an immutable collection of information (called the "one-way notification case" in the PSR-14 meta document) or it could offer some API for modification. Because a generic interface is lacking, listeners should always type hint against the Event class they are expecting. Custom interfaces or class hierarchies for grouping Events are of course possible and valid.

When an Event is dispatched, the dispatcher asks a ListenerProvider to find all listeners registered for the event in question:

$listeners = $listenerProvider->getListenersForEvent($event)

Then the dispatcher calls each listener and finally has to return the Event after the last listener has been called (or the event was marked as stopped) so that the dispatching code can proceed and may take the modified event into account.

And that is it!

For a more in depth introduction into PSR-14 and some background information, I recommend the talk "PSR-14: A major PHP Event" by Larry Garfield at the Dutch PHP Conference 2019:

This should be enough information on the general concepts of PSR-14. Time to get a little bit more specific.

It is the responsibility of the application or framework that implements PSR-14 to come up with or to choose an implementation of a ListenerProvider and an EventDispatcher. So let's see next how TYPO3 is doing these things.

Top

Integration into TYPO3

If you are unfamiliar with the symfony dependency injection container and compiler passes but still want to understand the next paragraph, head over to the post about Dependency Injection in TYPO3 and read the "Advanced Functionality" chapter in particular. =)

With TYPO3 10 a container based Dependency Injection was introduced using the symfony/dependency-injection component. The PSR-14 integration now builds on top of the dependency injection container. The TYPO3\CMS\Core\EventDispatcher\ListenerProvider gets populated by a ListenerProviderPass which is a CompilerPass implementation. This means that all listeners are registered and ordered at container compile time which should be the most performant way, because once the container is compiled there is no further calculation needed to collect listeners. Please have a look at the two classes for a better understanding of the internal mechanics if you're interested.

The TYPO3\CMS\Core\EventDispatcher\EventDispatcher is a rather straightforward class. It just asks the ListenerProvider for registered listeners for an $event and returns the Event back to the dispatching code after all listeners have been called or the event has been stopped:

namespace TYPO3\CMS\Core\EventDispatcher;

class EventDispatcher implements EventDispatcherInterface, SingletonInterface
{
    public function dispatch(object $event)
    {
        // If the event is already stopped, nothing to do here.
        if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
            return $event;
        }
        foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
            $listener($event);
            if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
                break;
            }
        }
        return $event;
    }
}

With the implementation details out of the way, we can now have a look on how all of this comes together in a few examples taken from the TYPO3 core.

Top

Use Events in TYPO3

First let's have a look on how we register listeners in TYPO3 and look at two examples afterwards.

Listener Registration

The CompilerPass solution for listener registration means, that in order to get recognized as a listener a class needs to be registered in a Services.(yaml|php) file because those files are consulted for the container compilation. Listeners have to be tagged as event.listener, because that is what the ListenerProviderPass is looking for.

A registration of a listener looks like this:

# EXT:my_ext/Configuration/Services.yaml
Services:
  Vendor\Extension\Listener\MyListener:
    tags:
      - name: event.listener
        identifier: 'ext-extension/myListener'
        method: 'myListenerMethod'
        event: Vendor\Extension\Something\SpecificEvent
        after: 'some-identifier'
        before: 'some-other-identifier, and-another-identifier'

This Services.yaml example registers a listener for an Event. Let's look at this line by line:

  • Line 2: The registration happens in the Services section. This is where all the Dependency Injection configuration goes as well
  • Line 3: The Full Qualified Class Name (FQCN) of the listener.
  • Line 4: The registration of listeners is done with service tags (official symfony documentation).
  • Line 5: The tag name for listener registration is event.listener. This tag will be recognized and processed by the before mentioned ListenerProviderPass.
  • Line 6: The identifier of the listener. This is used to order the listener before or after other listeners. If no identifier is specified the FQCN is the identifier.
  • Line 7: The method that should receive the Event. This is optional. If no method is configured the listener class itself is expected to be callable via __invoke() as shown in the 2nd example at the end of this post.
  • Line 8: The FQCN of the Event the listener is registered for.
  • Line 9: Optional listeners that need to run before our listener. We register after those.
  • Line 10: Optional listeners that need to run after our listener. We register before those.

Note: A listener class can have multiple event.listener tags.

A list of the ordered Event listeners has also been added to the configuration module of the system extension lowlevel. This is great for a quick search what listeners are registered for an Event.

This concludes the registration details. Let's move on.

Top

Replacement of Signals and Hooks

One task during the development of TYPO3 10 LTS is to migrate all Signals to Events. This means the code that emitted a Signal via extbases SignalSlot\Dispatcher will now instead create an Event object and pass this to the EventDispatcher. To be backwards compatible and still provide the old API, the Signal dispatching itself is moved to a listener for the Event (which is elegant and smart). On occasions where the core itself consumed a Signal in the past, a listener is registered instead, so that the core does no longer use Signals at all. If you are interested in the process, have a look at this example patch. Hooks will be tackled in a similar fashion but there are a lot of those and finishing the migration might take some time.

Extension authors are encouraged to switch to the introduced Events wherever possible and to not rely on the old Signals or Hooks. In fact many Signals that have been migrated are already deprecated (example Deprecation RST).

Now, let's finally look at some examples from the core than can be adopted for custom extensions.

Top

Example: EnrichFileMetaDataEvent

With Patch #61715 all FAL related Signals have been migrated to PSR-14 Events (Feature RST). Let's pick an example from among them that goes the full distance: a former Signal migrated to an Event that is consumed by the TYPO3 core itself. The method MetaDataRepository::findByFileUid() used to emit a Signal for meta data enrichment.

The old code looked like this:

public function findByFileUid($uid)
{
	...
	$passedData = new \ArrayObject($record);
	$this->emitRecordPostRetrievalSignal($passedData);
	return $passedData->getArrayCopy();
}

protected function emitRecordPostRetrievalSignal(\ArrayObject $data)
{
	$this->getSignalSlotDispatcher()->dispatch(self::class, 'recordPostRetrieval', [$data]);
}

To migrate this to PSR-14 the first thing we need is an object for this particular Event. That would be the EnrichFileMetaDataEvent:

/**
 * Event that is called after a record has been loaded from database
 * Allows other places to do extension of metadata at runtime or
 * for example translation and workspace overlay.
 */
final class EnrichFileMetaDataEvent
{
    /**
     * @var int
     */
    private $fileUid;

    /**
     * @var int
     */
    private $metaDataUid;

    /**
     * @var array
     */
    private $record;

    public function __construct(int $fileUid, int $metaDataUid, array $record)
    {
        $this->fileUid = $fileUid;
        $this->metaDataUid = $metaDataUid;
        $this->record = $record;
    }

    public function getFileUid(): int
    {
        return $this->fileUid;
    }

    public function getMetaDataUid(): int
    {
        return $this->metaDataUid;
    }

    public function getRecord(): array
    {
        return $this->record;
    }

    public function setRecord(array $record): void
    {
        $this->record = $record;
    }
}

This object offers three public methods for reading and one for modification. This is the full extent of what listeners are supposed to do with this Event. Listeners can access the file uid as well as the meta data uid but are not allowed to change those. This seems very reasonable and it is one of the great advantages of working with Event objects that the object can offer a clear and clean API of what are the intended options for listeners.

Great stuff.

Now the code that emitted the Signal has to dispatch the Event instead of the Signal. That's easy:

public function findByFileUid($uid)
{
    ...
    return $this->eventDispatcher->dispatch(
		new EnrichFileMetaDataEvent($uid, (int)$record['uid'], $record)
	)->getRecord();
}

Now we have a PSR-14 Event dispatched. Yay. But the Signal is gone and previously registered slots are no longer called. So, to be backwards compatible the next thing is to register a listener that emits the Signal again. This listener is registered in EXT:core/Configuration/Services.yaml:

Services:
  TYPO3\CMS\Core\Compatibility\SlotReplacement:
    tags:
      - name: event.listener,
        identifier: 'legacy-slot',
        method: 'onMetaDataRepositoryRecordPostRetrieval',
        event: TYPO3\CMS\Core\Resource\Event\EnrichFileMetaDataEvent

Now the method SlotReplacement::onMetaDataRepositoryRecordPostRetrieval() is called when the Event TYPO3\CMS\Core\Resource\Event\EnrichFileMetaDataEvent is dispatched. This listener does nothing more than emitting the previously removed Signal:

public function onMetaDataRepositoryRecordPostRetrieval(EnrichFileMetaDataEvent $event): void
{
  $data = $event->getRecord();
  $data = new \ArrayObject($data);
  $this->signalSlotDispatcher->dispatch(MetaDataRepository::class, 'recordPostRetrieval', [$data]);
  $event->setRecord($data->getArrayCopy());
}

Last piece of the puzzle is to migrate the previously registered slot to an event listener (Patch). For this a new listener is registered in EXT:frontend/Configuration/Services.yaml:

TYPO3\CMS\Frontend\Aspect\FileMetadataOverlayAspect:
  tags: 
	- name: event.listener
	  identifier: 'typo3-frontend/overlay'
	  method: 'languageAndWorkspaceOverlay'
	  event: TYPO3\CMS\Core\Resource\Event\EnrichFileMetaDataEvent

The method FileMetadataOverlayAspect::languageAndWorkspaceOverlay() was previously registered as a slot. It has to be slightly refactored to receive the EnrichFileMetaDataEvent now and to get the needed data from the Event object.

final class FileMetadataOverlayAspect
{
    public function languageAndWorkspaceOverlay(EnrichFileMetaDataEvent $event): void
    {
        $overlaidMetaData = $event->getRecord();
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class);
        $pageRepository->versionOL('sys_file_metadata', $overlaidMetaData);
        $overlaidMetaData = $pageRepository
            ->getLanguageOverlay(
                'sys_file_metadata',
                $overlaidMetaData
            );
        if ($overlaidMetaData !== null) {
            $event->setRecord($overlaidMetaData);
        }
    }
}

This concludes all steps necessary to migrate from a Signal to a PSR-14 Event. More examples of this can be found in the TYPO3 core. Extension authors are encouraged to migrate to the new Events and to not rely on the old Signals which will be removed in a later version of TYPO3.

Top

Example: AfterMailerInitializationEvent

Let's look at the AfterMailerInitializationEvent as a second example because the registered listener (that also just adds the old signal for backwards compatibility) uses the __invoke() method which means the listener class itself is the callable listener.

First, the registration:

# EXT:core/Configuration/Services.yaml
Services:
  TYPO3\CMS\Core\Compatibility\Slot\PostInitializeMailer:
    tags:
      - name: event.listener,
        identifier: 'legacy-slot',
        event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent

As mentioned above, pointing to a method of the listener is optional and can be omitted in case the listener class itself is callable. In this case no method is given and therefore the listener class has to implement __invoke():

class PostInitializeMailer
{
    /**
     * @var Dispatcher
     */
    protected $dispatcher;
	
    public function __construct(Dispatcher $dispatcher)
    {
        $this->dispatcher = $dispatcher;
    }
	
    public function __invoke(AfterMailerInitializationEvent $event): void
    {
        $this->dispatcher->dispatch(Mailer::class, 'postInitializeMailer', [$event->getMailer()]);
    }
}

To cite from the best practices section of the official documentation of the PSR-14 integration into TYPO3:

When configuring Listeners, it is recommended to add one Listener class per Event type, and have it called via __invoke().

With all this being said and the examples at hand you should be able to migrate your extension code to use PSR-14 Events and to dispatch Events rather than emitting Signals.

Thanks for reading and may you always listen to the right Events!

Top

Further Information