Nicolas Grekas
Contributed by Nicolas Grekas in #66122 , #66129 , #66131 and #66133

Symfony 8.2 includes lots of performance improvements, but we keep looking for new opportunities to make your applications faster.

For example, Symfony creates the event dispatcher at runtime for every request by calling addListener() for each listener and wrapping it in a closure, even for events that the request never dispatches. But Symfony already knows your application's event listeners when it compiles the container, so this work can be done ahead of time.

Compiled Event Dispatcher

In Symfony 8.2, the event_dispatcher service is a CompiledEventDispatcher. It receives an array of listener service IDs and methods, in the order they run, and a service locator to load them when needed. PHP's OPcache stores the array in shared memory, so each request doesn't need its own copy.

In a benchmark with 75 listeners across 31 events, creating the dispatcher took 0.48 µs, down from 33.7 µs. The listener closures previously used about 97 KB of memory; the compiled dispatcher doesn't need them.

Dispatching events is as fast as before. Listener services are still created only when they're about to run. If an event is never dispatched, or a listener stops its propagation, the remaining listeners aren't instantiated.

Your existing listener and subscriber registrations benefit from this automatically. The profiler and debug:event-dispatcher work as before.

Changing Listeners at Runtime Is Deprecated

Adding or removing a listener at runtime forces the compiled dispatcher to fall back to a regular EventDispatcher, losing the performance benefit. Symfony 8.2 therefore deprecates calling addListener(), addSubscriber(), removeListener() or removeSubscriber() on the shared event_dispatcher service. These calls also trigger deprecations in dev, where you can see them in the profiler.

Register your listeners as services instead. For temporary listeners, such as those needed while a console command runs, use the new ScopedEventDispatcher. It wraps the shared dispatcher and holds the extra listeners without adding them to the shared service. Only events dispatched through the wrapper reach those listeners:

1
2
3
4
5
6
7
8
9
use Symfony\Component\EventDispatcher\ScopedEventDispatcher;
use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener;
use Symfony\Component\Messenger\Worker;

$dispatcher = new ScopedEventDispatcher($this->eventDispatcher);
$dispatcher->addSubscriber(new StopWorkerOnMessageLimitListener(10));

$worker = new Worker($receivers, $bus, $dispatcher);
$worker->run();

The wrapper calls both sets of listeners in priority order. Symfony uses it in the messenger:consume and messenger:failed:retry commands.

Check your functional tests too. If a test adds a listener to the shared event_dispatcher to check that an event was dispatched, register that listener as a service in the test environment instead.

New Autowiring Type for the Event Dispatcher

Autowiring the shared dispatcher with Symfony\Component\EventDispatcher\EventDispatcherInterface is also deprecated, because that interface includes methods for changing listeners. If your service only dispatches events, switch to the contracts interface:

1
2
3
4
5
6
7
8
9
10
11
12
- use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

class OrderPlacer
{
    public function __construct(
        private EventDispatcherInterface $dispatcher,
    ) {
    }

    // ...
}

To inspect registered listeners, for example in a debugging tool, autowire Symfony\Contracts\EventDispatcher\ListenerIntrospectionInterface. This new interface provides getListeners(), getListenerPriority() and hasListeners(). If you also need to dispatch events, combine both interfaces with an intersection type:

1
2
3
4
5
6
7
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\EventDispatcher\ListenerIntrospectionInterface;

public function __construct(
    private EventDispatcherInterface&ListenerIntrospectionInterface $dispatcher,
) {
}
Published in #Living on the edge