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.
Table of Contents
- 1. What PSR-14 changes compared to a classic observer pattern
- 2. EventDispatcherInterface in detail
- 3. ListenerProviderInterface in detail
- 4. Building a minimal implementation yourself
- 5. Stoppable events: controlled propagation
- 6. Practical example: a domain event mechanism
- 7. Listener registration: attributes instead of manual wiring
- 8. Limits: ordering, error handling, and asynchronicity
- 9. When PSR-14 pays off compared to a hand rolled observer
- 10. Summary
- 11. FAQ
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.