[FEATURE] Introduce PSR-14-based EventDispatcher as alternative for hooks 03/61303/16
authorBenni Mack <benni@typo3.org>
Mon, 15 Jul 2019 18:31:38 +0000 (20:31 +0200)
committerBenjamin Franzke <bfr@qbus.de>
Tue, 16 Jul 2019 13:48:32 +0000 (15:48 +0200)
The new PSR-14 standard for dispatching Events (that is: to extend
a Framework without having to modify a frameworks' code) adds
a EventDispatcher object that can dispatch Event objects to
EventListeners.

In PSR-14 every dispatched event is an object. It uses PHP class names as
identifiers for events. Class hierarchies may be used to group events.

A ListenerProvider object collects available listeners from an extension
and allows to listen and/or modify data provided by the Event object.

The current implementation relies on a custom TYPO3-specific
ListenerProvider that is configured using Symfony's Dependency Injection
tags.

As an example the Mailer-postProcInitialization signal/slot is
replaced by an Event.

This first patch introduces the feature, and does not deprecate
anything yet. The most important part is that new Events
can use this API instead of Hooks in TYPO3 v10.

Short-Term goal is to deprecate SignalSlot dispatcher in TYPO3 v10,
and migrate all signals to the EventDispatcher.

Resolves: #88770
Releases: master
Change-Id: I3649ddb9b9340640199279e6af3c040bffc397fe
Signed-off-by: Benni Mack <benni@typo3.org>
Signed-off-by: Benjamin Franzke <bfr@qbus.de>
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/61303
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
22 files changed:
composer.json
composer.lock
typo3/sysext/core/Classes/Compatibility/Slot/PostInitializeMailer.php [new file with mode: 0644]
typo3/sysext/core/Classes/DependencyInjection/ListenerProviderPass.php [new file with mode: 0644]
typo3/sysext/core/Classes/EventDispatcher/EventDispatcher.php [new file with mode: 0644]
typo3/sysext/core/Classes/EventDispatcher/ListenerProvider.php [new file with mode: 0644]
typo3/sysext/core/Classes/Mail/Event/AfterMailerInitializationEvent.php [new file with mode: 0644]
typo3/sysext/core/Classes/Mail/Mailer.php
typo3/sysext/core/Configuration/Services.php
typo3/sysext/core/Configuration/Services.yaml
typo3/sysext/core/Documentation/Changelog/master/Feature-88770-PSR-14BasedEventDispatcher.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package1/Configuration/Services.yaml [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package2/Configuration/Services.yaml [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package3/Configuration/Services.yaml [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package4Cycle/Configuration/Services.yaml [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/DependencyInjection/ListenerProviderPassTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/EventDispatcher/EventDispatcherTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/EventDispatcher/ListenerProviderTest.php [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Mail/MailerTest.php
typo3/sysext/core/composer.json
typo3/sysext/lowlevel/Classes/Controller/ConfigurationController.php
typo3/sysext/lowlevel/Resources/Private/Language/locallang.xlf

index bcb6758..b4d9cfb 100644 (file)
@@ -46,6 +46,7 @@
                "nikic/php-parser": "^4.0",
                "phpdocumentor/reflection-docblock": "^4.3",
                "psr/container": "^1.0",
+               "psr/event-dispatcher": "^1.0",
                "psr/http-message": "~1.0",
                "psr/http-server-middleware": "^1.0",
                "psr/log": "~1.0.0",
index cec5b8a..1af7549 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "112098a7382ac5f79a2b4dfe5fa616a9",
+    "content-hash": "d1380344a1f0aac44298b30b03cf311e",
     "packages": [
         {
             "name": "cogpowered/finediff",
             "time": "2017-02-14T16:28:37+00:00"
         },
         {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "time": "2019-01-08T18:20:26+00:00"
+        },
+        {
             "name": "psr/http-message",
             "version": "1.0.1",
             "source": {
diff --git a/typo3/sysext/core/Classes/Compatibility/Slot/PostInitializeMailer.php b/typo3/sysext/core/Classes/Compatibility/Slot/PostInitializeMailer.php
new file mode 100644 (file)
index 0000000..207e2e8
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Compatibility\Slot;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent;
+use TYPO3\CMS\Core\Mail\Mailer;
+use TYPO3\CMS\Extbase\SignalSlot\Dispatcher;
+
+/**
+ * Bridge to provide backwards-compatibility for executing the SignalSlot dispatcher event.
+ */
+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()]);
+    }
+}
diff --git a/typo3/sysext/core/Classes/DependencyInjection/ListenerProviderPass.php b/typo3/sysext/core/Classes/DependencyInjection/ListenerProviderPass.php
new file mode 100644 (file)
index 0000000..4303da2
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\DependencyInjection;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
+use TYPO3\CMS\Core\Service\DependencyOrderingService;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+
+/**
+ * @internal
+ */
+final class ListenerProviderPass implements CompilerPassInterface
+{
+    /**
+     * @var string
+     */
+    private $tagName;
+
+    /**
+     * @param string $tagName
+     */
+    public function __construct(string $tagName)
+    {
+        $this->tagName = $tagName;
+    }
+
+    /**
+     * @param ContainerBuilder $container
+     */
+    public function process(ContainerBuilder $container)
+    {
+        $listenerProviderDefinition = $container->findDefinition(ListenerProvider::class);
+        if (!$listenerProviderDefinition) {
+            return;
+        }
+
+        $unorderedEventListeners = [];
+        foreach ($container->findTaggedServiceIds($this->tagName) as $serviceName => $tags) {
+            $container->findDefinition($serviceName)->setPublic(true);
+            foreach ($tags as $attributes) {
+                if (!isset($attributes['event'])) {
+                    throw new \InvalidArgumentException(
+                        'Service tag "event.listener" requires an event attribute to be defined, missing in: ' . $serviceName,
+                        1563217364
+                    );
+                }
+
+                $eventIdentifier = $attributes['event'];
+                $listenerIdentifier = $attributes['identifier'] ?? $serviceName;
+                $unorderedEventListeners[$eventIdentifier][$listenerIdentifier] = [
+                    'service' => $serviceName,
+                    'method' => $attributes['method'] ?? null,
+                    'before' => GeneralUtility::trimExplode(',', $attributes['before'] ?? '', true),
+                    'after' => GeneralUtility::trimExplode(',', $attributes['after'] ?? '', true),
+                ];
+            }
+        }
+
+        $dependencyOrderingService = new DependencyOrderingService();
+        foreach ($unorderedEventListeners as $eventName => $listeners) {
+            // Sort them
+            $listeners = $dependencyOrderingService->orderByDependencies($listeners);
+            // Configure ListenerProvider factory to include these listeners
+            foreach ($listeners as $listener) {
+                $listenerProviderDefinition->addMethodCall('addListener', [
+                    $eventName,
+                    $listener['service'],
+                    $listener['method'],
+                ]);
+            }
+        }
+    }
+}
diff --git a/typo3/sysext/core/Classes/EventDispatcher/EventDispatcher.php b/typo3/sysext/core/Classes/EventDispatcher/EventDispatcher.php
new file mode 100644 (file)
index 0000000..427ab39
--- /dev/null
@@ -0,0 +1,57 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\EventDispatcher;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\EventDispatcher\ListenerProviderInterface;
+use Psr\EventDispatcher\StoppableEventInterface;
+use TYPO3\CMS\Core\SingletonInterface;
+
+/**
+ * Base PSR-14 event dispatcher which has only one listener provider, given at runtime
+ * Is a singleton instance in order to be published once.
+ */
+class EventDispatcher implements EventDispatcherInterface, SingletonInterface
+{
+    /**
+     * @var ListenerProviderInterface
+     */
+    protected $listenerProvider;
+
+    public function __construct(ListenerProviderInterface $listenerProvider)
+    {
+        $this->listenerProvider = $listenerProvider;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    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;
+    }
+}
diff --git a/typo3/sysext/core/Classes/EventDispatcher/ListenerProvider.php b/typo3/sysext/core/Classes/EventDispatcher/ListenerProvider.php
new file mode 100644 (file)
index 0000000..c21b51f
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\EventDispatcher;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Container\ContainerInterface;
+use Psr\EventDispatcher\ListenerProviderInterface;
+
+/**
+ * Provides Listeners configured with the symfony service
+ * tag 'event.listener'.
+ *
+ * @internal
+ */
+class ListenerProvider implements ListenerProviderInterface
+{
+    /**
+     * @var ContainerInterface
+     */
+    protected $container;
+
+    /**
+     * @var array
+     */
+    protected $listeners = [];
+
+    public function __construct(ContainerInterface $container)
+    {
+        $this->container = $container;
+    }
+
+    /**
+     * Not part of the public API, used in the generated service factor for this class,
+     *
+     * @param string $event
+     * @param string $service
+     * @param string|null $method
+     * @internal
+     */
+    public function addListener(string $event, string $service, string $method = null): void
+    {
+        $this->listeners[$event][] = [
+            'service' => $service,
+            'method' => $method
+        ];
+    }
+
+    /**
+     * Not part of the public API, only used for debugging purposes
+     *
+     * @internal
+     */
+    public function getAllListenerDefinitions(): array
+    {
+        return $this->listeners;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    public function getListenersForEvent(object $event): iterable
+    {
+        $eventClasses = [get_class($event)];
+        $classParents = class_parents($event);
+        $classInterfaces = class_implements($event);
+        if (is_array($classParents) && !empty($classParents)) {
+            array_push($eventClasses, ...array_values($classParents));
+        }
+        if (is_array($classInterfaces) && !empty($classInterfaces)) {
+            array_push($eventClasses, ...array_values($classInterfaces));
+        }
+        foreach ($eventClasses as $className) {
+            if (isset($this->listeners[$className])) {
+                foreach ($this->listeners[$className] as $listener) {
+                    yield $this->getCallable($listener['service'], $listener['method']);
+                }
+            }
+        }
+    }
+
+    /**
+     * @param string $service
+     * @param string|null $method
+     * @return callable
+     * @throws \InvalidArgumentException
+     */
+    protected function getCallable(string $service, string $method = null): callable
+    {
+        $target = $this->container->get($service);
+        if ($method !== null) {
+            // Dispatch to configured method name instead of __invoke()
+            $target = [ $target, $method ];
+        }
+
+        if (!is_callable($target)) {
+            throw new \InvalidArgumentException(
+                sprintf('Event listener "%s%s%s" is not callable"', $service, ($method !== null ? '::' : ''), $method),
+                1549988537
+            );
+        }
+
+        return $target;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Mail/Event/AfterMailerInitializationEvent.php b/typo3/sysext/core/Classes/Mail/Event/AfterMailerInitializationEvent.php
new file mode 100644 (file)
index 0000000..07de9d8
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Mail\Event;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Mail\Mailer;
+
+/**
+ * This event is fired once a new Mailer is instantiated with specific transport settings.
+ * So it is possible to add custom mailing settings.
+ */
+final class AfterMailerInitializationEvent
+{
+    /**
+     * @var Mailer
+     */
+    private $mailer;
+
+    public function __construct(Mailer $mailer)
+    {
+        $this->mailer = $mailer;
+    }
+
+    public function getMailer(): Mailer
+    {
+        return $this->mailer;
+    }
+}
index cc45131..9f5bdf3 100644 (file)
@@ -14,6 +14,7 @@ namespace TYPO3\CMS\Core\Mail;
  * The TYPO3 project - inspiring people to share!
  */
 
+use Psr\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\Mailer\MailerInterface;
 use Symfony\Component\Mailer\SentMessage;
 use Symfony\Component\Mailer\SmtpEnvelope;
@@ -22,11 +23,11 @@ use Symfony\Component\Mime\Address;
 use Symfony\Component\Mime\Email;
 use Symfony\Component\Mime\NamedAddress;
 use Symfony\Component\Mime\RawMessage;
+use TYPO3\CMS\Core\EventDispatcher\EventDispatcher;
 use TYPO3\CMS\Core\Exception as CoreException;
+use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\MailUtility;
-use TYPO3\CMS\Extbase\Object\ObjectManager;
-use TYPO3\CMS\Extbase\SignalSlot\Dispatcher;
 
 /**
  * Adapter for Symfony/Mailer to be used by TYPO3 extensions.
@@ -76,7 +77,7 @@ class Mailer implements MailerInterface
                 throw new CoreException($e->getMessage(), 1291068569);
             }
         }
-        $this->emitPostInitializeMailerSignal();
+        $this->getEventDispatcher()->dispatch(new AfterMailerInitializationEvent($this));
     }
 
     /**
@@ -184,30 +185,10 @@ class Mailer implements MailerInterface
     }
 
     /**
-     * Get the object manager
-     *
-     * @return ObjectManager
-     */
-    protected function getObjectManager()
-    {
-        return GeneralUtility::makeInstance(ObjectManager::class);
-    }
-
-    /**
-     * Get the SignalSlot dispatcher
-     *
-     * @return Dispatcher
-     */
-    protected function getSignalSlotDispatcher()
-    {
-        return $this->getObjectManager()->get(Dispatcher::class);
-    }
-
-    /**
      * Emits a signal after mailer initialization
      */
-    protected function emitPostInitializeMailerSignal()
+    protected function getEventDispatcher(): EventDispatcherInterface
     {
-        $this->getSignalSlotDispatcher()->dispatch(self::class, 'postInitializeMailer', [$this]);
+        return GeneralUtility::makeInstance(EventDispatcher::class);
     }
 }
index 61fd425..dc3ef1b 100644 (file)
@@ -18,6 +18,7 @@ return function (ContainerConfigurator $container, ContainerBuilder $containerBu
 
     $containerBuilder->addCompilerPass(new DependencyInjection\SingletonPass('typo3.singleton'));
     $containerBuilder->addCompilerPass(new DependencyInjection\LoggerAwarePass('psr.logger_aware'));
+    $containerBuilder->addCompilerPass(new DependencyInjection\ListenerProviderPass('event.listener'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.middleware'));
     $containerBuilder->addCompilerPass(new DependencyInjection\PublicServicePass('typo3.request_handler'));
     $containerBuilder->addCompilerPass(new DependencyInjection\AutowireInjectMethodsPass());
index 74016a6..3ab5532 100644 (file)
@@ -14,6 +14,13 @@ services:
     arguments:
       $configPath: "%env(TYPO3:configPath)%/sites"
 
+  TYPO3\CMS\Core\EventDispatcher\EventDispatcher:
+    arguments:
+      $listenerProvider: '@TYPO3\CMS\Core\EventDispatcher\ListenerProvider'
+
+  TYPO3\CMS\Core\EventDispatcher\ListenerProvider:
+    public: true
+
   TYPO3\CMS\Core\Package\PackageManager:
     autoconfigure: false
 
@@ -28,3 +35,10 @@ services:
 
   TYPO3\CMS\Core\Database\Schema\SqlReader:
     public: true
+
+  # EventListeners
+  TYPO3\CMS\Core\Compatibility\Slot\PostInitializeMailer:
+    tags:
+      - { name: event.listener,
+          identifier: 'legacy-slot',
+          event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent }
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-88770-PSR-14BasedEventDispatcher.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-88770-PSR-14BasedEventDispatcher.rst
new file mode 100644 (file)
index 0000000..5afa972
--- /dev/null
@@ -0,0 +1,129 @@
+.. include:: ../../Includes.txt
+
+==============================================
+Feature: #88770 - PSR-14 based EventDispatcher
+==============================================
+
+See :issue:`88770`
+
+Description
+===========
+
+A new EventDispatcher system is added to extend TYPO3's Core behaviour via PHP code. In the past,
+this was done via Extbase's SignalSlot and TYPO3's custom hook system. The new EventDispatcher
+system is a fully capable replacement for new code in TYPO3, as well as a possibility to
+migrate away from previous TYPO3 solutions.
+
+PSR-14 [https://www.php-fig.org/psr/psr-14/] is a lean solution that builds upon wide-spread
+solutions for hooking into existing PHP code (Frameworks, CMS and the like).
+
+PSR-14 consists of four components:
+
+1. An `EventDispatcher` object that is used to trigger an Event. TYPO3 has a custom EventDispatcher
+implementation for now, however all EventDispatchers of all frameworks are implementing
+:php:`Psr\EventDispatcher\EventDispatcherInterface` thus it is possible to replace the event
+dispatcher with another. The EventDispatcher's main method :php:`dispatch()` is called in TYPO3 Core
+or extensions, that receives a PHP object and is then handed to all available listeners.
+
+2. A `ListenerProvider` object that contains all listeners which have been registered for all events.
+TYPO3 has a custom ListenerProvider that collects all listeners during compile time. This component
+is not exposed outside of TYPO3's Core Framework.
+
+3. Various `Event` objects. An event object can be any PHP object and is called from TYPO3 Core or
+an extension ("Emitter") containing all information to be transported to the listeners. By default,
+all registered listeners get triggered by an Event, however, if an Event has the interface
+:php:`Psr\EventDispatcher\StoppableEventInterface` implemented, a listener can stop further execution
+of other event listeners. This is especially useful if the listeners are candidates to provide information
+to the emitter. This allows to finish event dispatching, once this information has been acquired.
+
+If an event can be modified, appropriate methods should be available, although due to PHP's
+nature of handling objects and the PSR-14 Listener signature, it cannot be guaranteed to be immutable.
+
+4. Listeners: Extensions and PHP packages can add listeners that are registered. They are usually
+associated to Event objects by the name of the event (FQCN) to be listened on. It is the task of
+the `ListenerProvider` to provide configuration mechanisms to represent this relationship.
+
+The main benefits of the EventDispatcher approach over Hooks and Extbase's SignalSlot Dispatcher
+is an implementation which helps extension authors to better understand the possibilities
+by having a strongly typed system based on PHP. In addition, it serves as a bridge to also
+incorporate other Events provided by frameworks that support PSR-14.
+
+
+Impact
+======
+
+TYPO3's EventDispatcher serves as the basis to replace all Signal/Slots and hooks in the future,
+however for the time being, hooks and registered Slots work the same way as before, unless migrated
+to an EventDispatcher-like code, whereas a deprecation message can be triggered.
+
+Some hooks / signal/slots might not be replaced 1:1 to EventDispatcher, but rather superseded with
+a more robust or future-proof API.
+
+Registration:
+
+If an extension author wants to provide a custom Event Listener, an according entry with the tag
+`event.listener` can be added to the `Configuration/Services.yaml` file of that extension.
+
+Example:
+
+.. code-block:: yaml
+
+   services:
+     MyCompany\MyPackage\EventListener\NullMailer:
+       tags:
+         - { name: event.listener,
+             identifier: 'myListener',
+             event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent,
+             before: 'redirects, anotherIdentifier' }
+
+
+The tag name `event.listener` identifies that a listener should be registered.
+
+The custom PHP class `MyCompany\MyPackage\EventListener\NullMailer` serves as the listener,
+whereas the `identifier` is a common name so orderings can be built upon the identifier,
+the optional `before` and `after` attributes allow for custom sorting against `identifier`.
+
+The `event` attribute is the FQCN of the Event object.
+
+If no attribute `method` is given, the class is treated as Invokable, thus `__invoke` method is called.
+
+An example listener, which hooks into the Mailer API to modify Mailer settings to not send any emails,
+could look like this:
+
+.. code-block:: php
+
+   namespace MyCompany\MyPackage\EventListener;
+   use TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent;
+
+   class NullMailer
+   {
+       public function __invoke(AfterMailerInitializationEvent $event): void
+       {
+           $event->getMailer()->injectMailSettings(['transport' => 'null']);
+       }
+   }
+
+An extension can define multiple listeners.
+
+Once the emitter is triggering an Event, this listener is called automatically. Be sure
+to inspect the Event PHP class to fully understand the capabilities provided by an Event.
+
+Best Practices:
+
+1. When configuring Listeners, it is recommended to add one Listener class per Event type, and
+have it called via `__invoke()`.
+
+2. When creating a new Event PHP class, it is recommended to add a `Event` suffix to the PHP class,
+and to move it into an appropriate folder e.g. `Classes/Database/Event` to easily discover
+Events provided by a package. Be careful about the context that should be exposed.
+
+3. Emitters (TYPO3 Core or Extension Authors) should always use Dependency Injection to receive the
+EventDispatcher object as a constructor argument, where possible, by adding a type declaration
+for `Psr\EventDispatcher\EventDispatcherInterface`.
+
+Any kind of Event provided by TYPO3 Core falls under TYPO3's Core API deprecation policy, except
+for its constructor arguments, which may vary. Events that should only be used within TYPO3 Core,
+are marked as `@internal`, just like other non-API parts of TYPO3, but `@internal` Events will be
+avoided whenever technically possible.
+
+.. index:: PHP-API, ext:core
diff --git a/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package1/Configuration/Services.yaml b/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package1/Configuration/Services.yaml
new file mode 100644 (file)
index 0000000..0090d5b
--- /dev/null
@@ -0,0 +1,17 @@
+services:
+
+  package1.listener1:
+    class: stdClass
+    tags:
+      - { name: event.listener,
+          identifier: 'legacy-hook',
+          event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent,
+          before: 'unavailable' }
+
+  package1.listener2:
+    class: stdClass
+    tags:
+      - { name: event.listener,
+          identifier: 'legacy-hook',
+          event: TYPO3\CMS\Core\Foo\Event\TestEvent }
+
diff --git a/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package2/Configuration/Services.yaml b/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package2/Configuration/Services.yaml
new file mode 100644 (file)
index 0000000..7631d69
--- /dev/null
@@ -0,0 +1,10 @@
+services:
+
+  package2.listener:
+    class: stdClass
+    tags:
+      - { name: event.listener,
+          event: TYPO3\CMS\Core\Mail\Event\AfterMailerInitializationEvent,
+          method: 'onEvent',
+          before: 'legacy-hook' }
+
diff --git a/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package3/Configuration/Services.yaml b/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package3/Configuration/Services.yaml
new file mode 100644 (file)
index 0000000..880a078
--- /dev/null
@@ -0,0 +1,10 @@
+services:
+
+  package3.listener:
+    class: stdClass
+    tags:
+      # overwrites listener2 from Package 1 by specifying the same identifier
+      - { name: event.listener,
+          identifier: 'legacy-hook',
+          event: TYPO3\CMS\Core\Foo\Event\TestEvent }
+
diff --git a/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package4Cycle/Configuration/Services.yaml b/typo3/sysext/core/Tests/Unit/DependencyInjection/Fixtures/Package4Cycle/Configuration/Services.yaml
new file mode 100644 (file)
index 0000000..0ace361
--- /dev/null
@@ -0,0 +1,10 @@
+services:
+
+  package4.listener:
+    class: stdClass
+    tags:
+      - { name: event.listener,
+          event: TYPO3\CMS\Core\Foo\Event\TestEvent,
+          before: 'legacy-hook',
+          after: 'legacy-hook' }
+
diff --git a/typo3/sysext/core/Tests/Unit/DependencyInjection/ListenerProviderPassTest.php b/typo3/sysext/core/Tests/Unit/DependencyInjection/ListenerProviderPassTest.php
new file mode 100644 (file)
index 0000000..383582f
--- /dev/null
@@ -0,0 +1,106 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\DependencyInjection;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
+use Symfony\Component\DependencyInjection\Reference;
+use TYPO3\CMS\Core\DependencyInjection\ListenerProviderPass;
+use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class ListenerProviderPassTest extends UnitTestCase
+{
+    protected function getContainerWithListenerProvider(array $packages = [])
+    {
+        $container = new ContainerBuilder();
+
+        foreach ($packages as $package) {
+            $yamlFileLoader = new YamlFileLoader($container, new FileLocator($package . '/Configuration'));
+            $yamlFileLoader->load('Services.yaml');
+        }
+
+        $listenerProvider = new Definition(ListenerProvider::class);
+        $listenerProvider->setPublic(true);
+        $listenerProvider->setArguments([
+            new Reference('service_container')
+        ]);
+
+        $container->setDefinition(ListenerProvider::class, $listenerProvider);
+
+        $container->addCompilerPass(new ListenerProviderPass('event.listener'));
+        $container->compile();
+
+        return $container;
+    }
+
+    public function testSimpleChainsAndDependencies()
+    {
+        $container = $this->getContainerWithListenerProvider([
+            __DIR__ . '/Fixtures/Package1',
+            __DIR__ . '/Fixtures/Package2',
+            __DIR__ . '/Fixtures/Package3',
+        ]);
+
+        $listenerProvider = $container->get(ListenerProvider::class);
+        $listeners = $listenerProvider->getAllListenerDefinitions();
+
+        $this->assertEquals(
+            [
+                'TYPO3\\CMS\\Core\\Mail\\Event\\AfterMailerInitializationEvent' => [
+                    [
+                        'service' => 'package2.listener',
+                        'method' => 'onEvent',
+                    ],
+                    [
+                        'service' => 'package1.listener1',
+                        'method' => null,
+                    ]
+                ],
+                'TYPO3\\CMS\\Core\\Foo\\Event\\TestEvent' => [
+                    [
+                        'service' => 'package3.listener',
+                        'method' => null,
+                    ]
+                ],
+            ],
+            $listeners
+        );
+    }
+
+    public function testCycleException()
+    {
+        $this->expectException(\UnexpectedValueException::class);
+        $this->expectExceptionMessage('Your dependencies have cycles. That will not work out. Cycles found: legacy-hook->package4.listener, package4.listener->legacy-hook');
+
+        $container = $this->getContainerWithListenerProvider([
+            __DIR__ . '/Fixtures/Package1',
+            __DIR__ . '/Fixtures/Package4Cycle',
+        ]);
+    }
+
+    public function testWithoutConfiguration()
+    {
+        $container = $this->getContainerWithListenerProvider([]);
+
+        $listenerProvider = $container->get(ListenerProvider::class);
+        $listeners = $listenerProvider->getAllListenerDefinitions();
+
+        $this->assertEquals([], $listeners);
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/EventDispatcher/EventDispatcherTest.php b/typo3/sysext/core/Tests/Unit/EventDispatcher/EventDispatcherTest.php
new file mode 100644 (file)
index 0000000..c9ee029
--- /dev/null
@@ -0,0 +1,206 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\EventDispatcher;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Prophecy\Prophecy\ObjectProphecy;
+use Psr\EventDispatcher\EventDispatcherInterface;
+use Psr\EventDispatcher\ListenerProviderInterface;
+use Psr\EventDispatcher\StoppableEventInterface;
+use TYPO3\CMS\Core\EventDispatcher\EventDispatcher;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class EventDispatcherTest extends UnitTestCase
+{
+    /**
+     * @var ListenerProviderInterface|ObjectProphecy
+     */
+    protected $containerProphecy;
+
+    /**
+     * @var EventDispatcher
+     */
+    protected $eventDispatcher;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->listenerProviderProphecy = $this->prophesize();
+        $this->listenerProviderProphecy->willImplement(ListenerProviderInterface::class);
+
+        $this->eventDispatcher = new EventDispatcher(
+            $this->listenerProviderProphecy->reveal()
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function implementsPsrInterface()
+    {
+        $this->assertInstanceOf(EventDispatcherInterface::class, $this->eventDispatcher);
+    }
+
+    /**
+     * @test
+     * @dataProvider callables
+     */
+    public function dispatchesEvent(callable $callable)
+    {
+        $event = new \stdClass;
+        $event->invoked = 0;
+
+        $this->listenerProviderProphecy->getListenersForEvent($event)->will(function () use ($callable): iterable {
+            yield $callable;
+        });
+
+        $ret = $this->eventDispatcher->dispatch($event);
+        $this->assertSame($event, $ret);
+        $this->assertEquals(1, $event->invoked);
+    }
+
+    /**
+     * @test
+     * @dataProvider callables
+     */
+    public function doesNotDispatchStoppedEvent(callable $callable)
+    {
+        $event = new class implements StoppableEventInterface {
+            public $invoked = 0;
+
+            public function isPropagationStopped(): bool
+            {
+                return true;
+            }
+        };
+
+        $this->listenerProviderProphecy->getListenersForEvent($event)->will(function () use ($callable): iterable {
+            yield $callable;
+        });
+
+        $ret = $this->eventDispatcher->dispatch($event);
+        $this->assertSame($event, $ret);
+        $this->assertEquals(0, $event->invoked);
+    }
+
+    /**
+     * @test
+     * @dataProvider callables
+     */
+    public function dispatchesMultipleListeners(callable $callable)
+    {
+        $event = new \stdClass;
+        $event->invoked = 0;
+
+        $this->listenerProviderProphecy->getListenersForEvent($event)->will(function () use ($callable): iterable {
+            yield $callable;
+            yield $callable;
+        });
+
+        $ret = $this->eventDispatcher->dispatch($event);
+        $this->assertSame($event, $ret);
+        $this->assertEquals(2, $event->invoked);
+    }
+
+    /**
+     * @test
+     * @dataProvider callables
+     */
+    public function stopsOnStoppedEvent(callable $callable)
+    {
+        $event = new class implements StoppableEventInterface {
+            public $invoked = 0;
+            public $stopped = false;
+
+            public function isPropagationStopped(): bool
+            {
+                return $this->stopped;
+            }
+        };
+
+        $this->listenerProviderProphecy->getListenersForEvent($event)->will(function () use ($callable): iterable {
+            yield $callable;
+            yield function (object $event): void {
+                $event->invoked += 1;
+                $event->stopped = true;
+            };
+            yield $callable;
+        });
+
+        $ret = $this->eventDispatcher->dispatch($event);
+        $this->assertSame($event, $ret);
+        $this->assertEquals(2, $event->invoked);
+    }
+
+    /**
+     * @test
+     */
+    public function listenerExceptionIsPropagated()
+    {
+        $this->expectException(\BadMethodCallException::class);
+        $this->expectExceptionCode(1563270337);
+
+        $event = new \stdClass;
+
+        $this->listenerProviderProphecy->getListenersForEvent($event)->will(function (): iterable {
+            yield function (object $event): void {
+                throw new \BadMethodCallException('some invalid state', 1563270337);
+            };
+        });
+
+        $this->eventDispatcher->dispatch($event);
+    }
+
+    /**
+     * Provider for callables.
+     * Either an invokable, class/method combination or a closure.
+     */
+    public function callables(): array
+    {
+        return [
+            [
+                // Invokable
+                new class {
+                    public function __invoke(object $event): void
+                    {
+                        $event->invoked += 1;
+                    }
+                },
+            ],
+            [
+                // Class + method
+                [
+                    new class {
+                        public function onEvent(object $event): void
+                        {
+                            $event->invoked += 1;
+                        }
+                    },
+                    'onEvent',
+                ]
+            ],
+            [
+                // Closure
+                function (object $event): void {
+                    $event->invoked += 1;
+                },
+            ]
+        ];
+    }
+}
diff --git a/typo3/sysext/core/Tests/Unit/EventDispatcher/ListenerProviderTest.php b/typo3/sysext/core/Tests/Unit/EventDispatcher/ListenerProviderTest.php
new file mode 100644 (file)
index 0000000..71a1a72
--- /dev/null
@@ -0,0 +1,212 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Unit\EventDispatcher;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Prophecy\Prophecy\ObjectProphecy;
+use Psr\Container\ContainerInterface;
+use Psr\EventDispatcher\ListenerProviderInterface;
+use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+/**
+ * Test case
+ */
+class ListenerProviderTest extends UnitTestCase
+{
+    /**
+     * @var ContainerInterface|ObjectProphecy
+     */
+    protected $containerProphecy;
+
+    /**
+     * @var ListenerProvider
+     */
+    protected $listenerProvider;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->containerProphecy = $this->prophesize();
+        $this->containerProphecy->willImplement(ContainerInterface::class);
+
+        $this->listenerProvider = new ListenerProvider(
+            $this->containerProphecy->reveal()
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function implementsPsrInterface()
+    {
+        $this->assertInstanceOf(ListenerProviderInterface::class, $this->listenerProvider);
+    }
+
+    /**
+     * @test
+     */
+    public function addedListenersAreReturnedByGetAllListenerDefinitions()
+    {
+        $this->listenerProvider->addListener('Event\\Name', 'listener1');
+        $this->listenerProvider->addListener('Event\\Name', 'listener2', 'methodName');
+
+        $this->assertEquals($this->listenerProvider->getAllListenerDefinitions(), [
+            'Event\\Name' => [
+                [ 'service' => 'listener1', 'method' => null ],
+                [ 'service' => 'listener2', 'method' => 'methodName' ],
+            ]
+        ]);
+    }
+
+    /**
+     * @test
+     * @dataProvider listeners
+     */
+    public function dispatchesEvent($listener, string $method = null)
+    {
+        $event = new \stdClass;
+        $event->invoked = 0;
+
+        $this->containerProphecy->get('listener')->willReturn($listener);
+        $this->listenerProvider->addListener(\stdClass::class, 'listener', $method);
+
+        foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
+            $listener($event);
+        }
+
+        $this->assertEquals(1, $event->invoked);
+    }
+
+    /**
+     * @test
+     * @dataProvider listeners
+     */
+    public function associatesToEventParentClass($listener, string $method = null)
+    {
+        $extendedEvent = new class extends \stdClass {
+            public $invoked = 0;
+        };
+
+        $this->containerProphecy->get('listener')->willReturn($listener);
+        $this->listenerProvider->addListener(\stdClass::class, 'listener', $method);
+        foreach ($this->listenerProvider->getListenersForEvent($extendedEvent) as $listener) {
+            $listener($extendedEvent);
+        }
+
+        $this->assertEquals(1, $extendedEvent->invoked);
+    }
+
+    /**
+     * @test
+     * @dataProvider listeners
+     */
+    public function associatesToImplementedInterfaces($listener, string $method = null)
+    {
+        $eventImplementation = new class implements \IteratorAggregate {
+            public $invoked = 0;
+
+            public function getIterator(): \Traversable
+            {
+                throw new \BadMethodCallException;
+            }
+        };
+
+        $this->containerProphecy->get('listener')->willReturn($listener);
+        $this->listenerProvider->addListener(\IteratorAggregate::class, 'listener', $method);
+        foreach ($this->listenerProvider->getListenersForEvent($eventImplementation) as $listener) {
+            $listener($eventImplementation);
+        }
+
+        $this->assertEquals(1, $eventImplementation->invoked);
+    }
+
+    /**
+     * @test
+     */
+    public function addListenerPreservesOrder()
+    {
+        $this->listenerProvider->addListener(\stdClass::class, 'listener1');
+        $this->listenerProvider->addListener(\stdClass::class, 'listener2');
+
+        $event = new \stdClass;
+        $event->sequence = '';
+        $this->containerProphecy->get('listener1')->willReturn(function (object $event): void {
+            $event->sequence .= 'a';
+        });
+        $this->containerProphecy->get('listener2')->willReturn(function (object $event): void {
+            $event->sequence .= 'b';
+        });
+        foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
+            $listener($event);
+        }
+
+        $this->assertEquals('ab', $event->sequence);
+    }
+
+    /**
+     * @test
+     */
+    public function throwsExceptionForInvalidCallable()
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        $this->expectExceptionCode(1549988537);
+
+        $event = new \stdClass;
+        $this->containerProphecy->get('listener')->willReturn(new \stdClass);
+        $this->listenerProvider->addListener(\stdClass::class, 'listener');
+        foreach ($this->listenerProvider->getListenersForEvent($event) as $listener) {
+            $listener($event);
+        }
+    }
+
+    /**
+     * Provider for event listeners.
+     * Either an invokable, class/method combination or a closure.
+     */
+    public function listeners(): array
+    {
+        return [
+            [
+                // Invokable
+                'listener' => new class {
+                    public function __invoke(object $event): void
+                    {
+                        $event->invoked = 1;
+                    }
+                },
+                'method' => null,
+            ],
+            [
+                // Class + method
+                'listener' => new class {
+                    public function onEvent(object $event): void
+                    {
+                        $event->invoked = 1;
+                    }
+                },
+                'method' => 'onEvent',
+            ],
+            [
+                // Closure
+                'listener' => function (object $event): void {
+                    $event->invoked = 1;
+                },
+                'method' => null,
+            ]
+        ];
+    }
+}
index de0ca0b..4a0a711 100644 (file)
@@ -20,6 +20,7 @@ use Symfony\Component\Mailer\Transport\NullTransport;
 use Symfony\Component\Mailer\Transport\SendmailTransport;
 use Symfony\Component\Mailer\Transport\TransportInterface;
 use TYPO3\CMS\Core\Controller\ErrorPageController;
+use TYPO3\CMS\Core\EventDispatcher\EventDispatcher;
 use TYPO3\CMS\Core\Exception;
 use TYPO3\CMS\Core\Mail\DelayedTransportInterface;
 use TYPO3\CMS\Core\Mail\Mailer;
@@ -48,8 +49,12 @@ class MailerTest extends UnitTestCase
     protected function setUp(): void
     {
         parent::setUp();
+        $eventDispatcher = $this->prophesize(EventDispatcher::class);
+        $eventDispatcher->dispatch(Argument::cetera())->willReturnArgument(0);
+        GeneralUtility::setSingletonInstance(EventDispatcher::class, $eventDispatcher->reveal());
+
         $this->subject = $this->getMockBuilder(Mailer::class)
-            ->setMethods(['emitPostInitializeMailerSignal'])
+            ->setMethods(null)
             ->disableOriginalConstructor()
             ->getMock();
     }
index e34079e..4fad777 100644 (file)
@@ -27,6 +27,7 @@
                "guzzlehttp/guzzle": "^6.3.0",
                "nikic/php-parser": "^4.0",
                "psr/container": "^1.0",
+               "psr/event-dispatcher": "^1.0",
                "psr/http-message": "~1.0",
                "psr/http-server-handler": "^1.0",
                "psr/http-server-middleware": "^1.0",
index a9d624e..4235338 100644 (file)
@@ -22,6 +22,7 @@ use TYPO3\CMS\Backend\Configuration\SiteTcaConfiguration;
 use TYPO3\CMS\Backend\Routing\Router;
 use TYPO3\CMS\Backend\Template\ModuleTemplate;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\EventDispatcher\ListenerProvider;
 use TYPO3\CMS\Core\Http\HtmlResponse;
 use TYPO3\CMS\Core\Localization\LanguageService;
 use TYPO3\CMS\Core\Utility\ArrayUtility;
@@ -120,6 +121,10 @@ class ConfigurationController
             'label' => 'siteConfiguration',
             'type' => 'siteConfiguration',
         ],
+        'eventListeners' => [
+            'label' => 'eventListeners',
+            'type' => 'eventListeners',
+        ],
     ];
 
     /**
@@ -235,6 +240,11 @@ class ConfigurationController
             $renderArray['raw'] = $this->container->get('middlewares');
         } elseif ($selectedTreeDetails['type'] === 'siteConfiguration') {
             $renderArray = GeneralUtility::makeInstance(SiteTcaConfiguration::class)->getTca();
+        } elseif ($selectedTreeDetails['type'] === 'eventListeners') {
+            // Keep the order of the keys
+            $sortKeysByName = false;
+            $listenerProvider = $this->container->get(ListenerProvider::class);
+            $renderArray = $listenerProvider->getAllListenerDefinitions();
         } else {
             throw new \RuntimeException('Unknown array type "' . $selectedTreeDetails['type'] . '"', 1507845662);
         }
index 8888bfc..9504a2c 100644 (file)
@@ -36,6 +36,9 @@
                        <trans-unit id="siteConfiguration">
                                <source>Site Configuration</source>
                        </trans-unit>
+                       <trans-unit id="eventListeners">
+                               <source>Event Listeners (PSR-14)</source>
+                       </trans-unit>
                        <trans-unit id="t3services">
                                <source>$GLOBALS['T3_SERVICES'] (Registered Services)</source>
                        </trans-unit>