Implementing a PSR-14 Event Dispatcher From Scratch
AI generated
8.4
PHP · PSR-14 · Events
Implementing a PSR-14 Event Dispatcher From Scratch
From the observer pattern to a standardized contract

Almost every PHP developer has built their own observer pattern at some point, usually a subject class with a list of callbacks. PSR-14 formalizes that pattern into two cleanly separated contracts. We implement both ourselves, including stoppable events, and build a practical domain event mechanism from it without any framework.

15 min read EventDispatcherInterface ListenerProviderInterface StoppableEventInterface Domain Events

1. What PSR-14 changes compared to a classic observer pattern

A classic observer pattern in PHP usually consists of a subject class with an internal list of observers and a notify() method that loops through all of them in a fixed order. That structure works, but it tightly couples registration and execution to a single class, and every extension with a new event type usually means new methods on that same subject class.

PSR-14 dissolves that coupling by defining two independent contracts: an EventDispatcherInterface that simply accepts an event and passes it along, and a completely separate ListenerProviderInterface that returns the matching listeners for a given event. This separation lets you swap listener resolution and dispatch logic independently of each other, for example to later load listeners from a configuration file instead of from code.

2. EventDispatcherInterface in detail

EventDispatcherInterface defines exactly one method, dispatch(object $event): object. It accepts any object as an event and returns that same object, possibly with changes listeners have made to it. There is deliberately no type restriction to a base event interface, any PHP object can serve as an event.

This openness sets PSR-14 apart from many older event systems that treat events as strings with payload arrays. An event as a typed object enables IDE autocompletion, static analysis with PHPStan, and the ability to give the event its own methods, for example to query its processing state or encapsulate additional data.


<?php

declare(strict_types=1);

namespace Psr\EventDispatcher;

interface EventDispatcherInterface
{
    /**
     * Passes the event on to registered listeners and returns it
     * afterward, potentially modified by those listeners.
     */
    public function dispatch(object $event): object;
}

3. ListenerProviderInterface in detail

ListenerProviderInterface defines the method getListenersForEvent(object $event): iterable. It accepts the event and returns an iterable set of callables responsible for that specific event. The return type iterable instead of array is a deliberate choice, since listeners can also be delivered lazily via a generator, which saves memory when many listeners are registered.

The split between dispatcher and provider means concretely: the dispatcher knows nothing about how listeners are resolved, it simply asks the provider and then calls every returned listener with the event as an argument. This decoupling makes it possible to combine multiple providers, for example one for attribute based listeners and a second for listeners sourced from a YAML configuration.


<?php

declare(strict_types=1);

namespace Psr\EventDispatcher;

interface ListenerProviderInterface
{
    /**
     * @return iterable<callable(object): void>
     */
    public function getListenersForEvent(object $event): iterable;
}

4. Building a minimal implementation yourself

A simple implementation of ListenerProviderInterface internally manages a map from event class names to listener arrays. When registering, the event class name is used as the key, and when querying, the implementation checks with instanceof whether the concrete event object matches one of the registered class names, which also accounts for inheritance.

The dispatcher itself stays deliberately thin: it calls getListenersForEvent(), iterates over the result and calls every listener with the event. Additionally, after each listener call it checks whether the event implements StoppableEventInterface and whether propagation has already been stopped, more on that in the next section.


<?php

declare(strict_types=1);

namespace App\Events;

use Psr\EventDispatcher\ListenerProviderInterface;

final class SimpleListenerProvider implements ListenerProviderInterface
{
    /** @var array<class-string, list<callable(object): void>> */
    private array $listeners = [];

    /**
     * @param callable(object): void $listener
     */
    public function addListener(string $eventClass, callable $listener): void
    {
        $this->listeners[$eventClass][] = $listener;
    }

    public function getListenersForEvent(object $event): iterable
    {
        foreach ($this->listeners as $eventClass => $listeners) {
            if ($event instanceof $eventClass) {
                yield from $listeners;
            }
        }
    }
}

5. Stoppable events: controlled propagation

StoppableEventInterface adds a single method to an event, isPropagationStopped(): bool. If this method returns true, the dispatcher must abort execution of further listeners for that event, not by silently ignoring the rest of the list, but by actively checking and stopping. Unlike a classic observer pattern, this behavior is defined by the standard itself, not left to individual implementation convention.

In practice this means: an event that should be stoppable implements an internal private flag and a stopPropagation() method that sets it. A listener that wants to prevent further processing calls that method instead of throwing an exception. That differs fundamentally from exception based control, because the rest of the application continues normally after dispatch, just without further listener calls for that one event.


<?php

declare(strict_types=1);

namespace App\Events;

use Psr\EventDispatcher\StoppableEventInterface;

final class OrderPlaced implements StoppableEventInterface
{
    private bool $propagationStopped = false;

    public function __construct(
        public readonly string $orderId,
        public readonly float $totalAmount,
    ) {
    }

    public function stopPropagation(): void
    {
        $this->propagationStopped = true;
    }

    public function isPropagationStopped(): bool
    {
        return $this->propagationStopped;
    }
}

// Inside the dispatcher: check after every listener whether to keep going
foreach ($this->provider->getListenersForEvent($event) as $listener) {
    if ($event instanceof StoppableEventInterface && $event->isPropagationStopped()) {
        break;
    }
    $listener($event);
}

6. Practical example: a domain event mechanism

Domain events encapsulate business occurrences, for example that an order was placed, without the code that triggers the event needing to know who reacts to it. An OrderService that persists an order afterward dispatches an OrderPlaced event, regardless of whether an email notification, a stock booking, or analytics tracking is registered as a listener later on.

That decoupling is the real value of PSR-14 in practice: new reactions to an existing event can be added without ever touching OrderService again. For registration, PHP 8.4 lends itself to an attribute based approach, where listener methods are marked with a custom attribute and a registration class collects those attributes via reflection at runtime or during container build.


<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Events\OrderPlaced;
use Psr\EventDispatcher\EventDispatcherInterface;

final class OrderService
{
    public function __construct(
        private readonly OrderRepositoryInterface $orders,
        private readonly EventDispatcherInterface $dispatcher,
    ) {
    }

    public function place(Order $order): void
    {
        $this->orders->save($order);

        // Announce the business occurrence without knowing the listeners
        $this->dispatcher->dispatch(
            new OrderPlaced($order->id, $order->totalAmount),
        );
    }
}

7. Listener registration: attributes instead of manual wiring

Instead of manually registering listeners via addListener(), a custom PHP attribute enables a declarative approach. A method gets marked with #[AsEventListener(OrderPlaced::class)], and a collector class scans all relevant classes for that attribute via reflection at application startup, automatically populating the listener provider.

This approach removes a lot of boilerplate, but it has a cost: reflection based scanning should not run again on every request in production, but once at build time or on first application startup, followed by caching the resolved mapping in a simple array structure or file. Without that caching, many registered listeners cause a noticeable performance overhead from repeated reflection calls.

8. Limits: ordering, error handling, and asynchronicity

PSR-14 does not define an order between listeners for the same event, that is deliberately left to the implementation. Anyone who needs a specific execution order has to model it themselves via priorities inside the listener provider, for example by registering listeners with a priority and sorting before returning them.

Nor does the standard define what happens when a listener throws an exception. In your own implementation you have to deliberately decide whether a failing listener stops the rest of the processing, or whether errors are collected and handled together after all listeners have run. For genuinely asynchronous processing, for example an email sent minutes later, PSR-14 is also the wrong building block, message queues are the right tool for that, because PSR-14 works synchronously within the same request.

9. When PSR-14 pays off compared to a hand rolled observer

For very small scripts with a single event type, a simple observer pattern is often quicker to build and understandable enough on its own. But once multiple event types, multiple listener sources, or a test suite with mock dispatchers enter the picture, the split between dispatcher and provider pays off noticeably, because both sides can be swapped and tested independently.

Interoperability also speaks for PSR-14: libraries that follow the standard can be combined with your own dispatcher without writing adapters. Anyone already working with Symfony or Laminas is using PSR-14 compatible implementations anyway, and building it yourself remains most relevant for learning purposes and for very lean, framework free applications.

Trait Classic observer pattern PSR-14 from scratch Framework implementation
Contracts Usually a single interface Two separate interfaces Two separate interfaces plus extensions
Listener source Registered fixed in code Swappable provider Attributes, config, container tags
Stoppable events Usually simulated via exceptions StoppableEventInterface in the standard StoppableEventInterface in the standard
Interoperability Not standardized PSR-14 compliant PSR-14 compliant, plus framework tooling
Effort Minimal Low to medium None, already available

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

PSR-14 Event Dispatcher: The Essentials at a Glance

Two contracts

EventDispatcherInterface passes events along, ListenerProviderInterface resolves the matching listeners.

Open events

Any PHP object can serve as an event, there is no requirement for a base interface.

Stoppable events

isPropagationStopped() allows deliberately aborting the listener chain without exceptions.

Domain events

Business occurrences get dispatched without the trigger needing to know its listeners.

11. FAQ: PSR-14 Event Dispatcher: The Essentials at a Glance

1What is the main difference between PSR-14 and a classic observer pattern?
PSR-14 splits dispatch logic and listener resolution into two independent interfaces, while a classic observer pattern usually bundles both into a single subject class.
2Does an event have to implement a specific base interface?
No, EventDispatcherInterface accepts any object as an event. Only if you need stoppable propagation do you additionally implement StoppableEventInterface.
3How does stoppable propagation work technically?
The dispatcher checks after every listener call whether the event implements StoppableEventInterface and whether isPropagationStopped() returns true, then aborts the iteration.
4Does PSR-14 define the order of listeners?
No, order is deliberately not standardized. Anyone who needs a specific execution order has to model it themselves inside the listener provider via priorities.
5What happens if a listener throws an exception?
The standard makes no statement about that, it is an implementation detail. A custom implementation has to decide itself whether an error stops processing or gets collected.
6Is PSR-14 suitable for asynchronous processing?
No, PSR-14 works synchronously within the same request. For processing that should happen at a later point in time, message queues are the more suitable tool.
7How do I register listeners without much boilerplate?
With a custom PHP attribute like AsEventListener that marks methods, combined with a reflection based collector class that populates the listener provider at application startup.
8Why is iterable instead of array the return type of getListenersForEvent?
iterable also allows generators, so listeners can be delivered lazily when needed, saving memory when many listeners are registered.
9Is a custom event dispatcher worth it for a Magento adjacent project?
For framework independent domain logic, yes. For the Magento application itself, Magento's own event system is the right choice since it is deeply integrated into the lifecycle.
10When should I use an existing PSR-14 implementation instead of building one myself?
As soon as a project already uses Symfony, Laminas, or another PSR-14 compatible library, the tested implementation is almost always the better choice over building one yourself.