DEV Community

Cover image for PSR-14 Event Dispatcher: Framework-Agnostic Domain Events in PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

PSR-14 Event Dispatcher: Framework-Agnostic Domain Events in PHP


You have an Order aggregate that records an OrderPlaced event when a customer checks out. Two teams want to react: billing sends an invoice, and the warehouse reserves stock. So you reach for the tool your framework hands you.

On Laravel that means the event extends nothing special but your listener lives in app/Listeners, wired through EventServiceProvider, and dispatched with event($orderPlaced). On Symfony it means your listener is tagged kernel.event_listener and the dispatcher is Symfony\Component\EventDispatcher\EventDispatcherInterface.

Both work. Both also drag a framework class into the one layer that is supposed to stay clean: your domain. The day you extract that domain into a package, or port the service from Laravel to Symfony, or write a plain PHPUnit test with no kernel, every use Illuminate\... in your event code becomes a wall.

PSR-14 is the standard that removes the wall.

Three tiny interfaces, that's the whole spec

PSR-14 is small enough to read in a coffee break. It defines three interfaces in the Psr\EventDispatcher namespace, and your domain only ever touches one of them.

<?php

namespace Psr\EventDispatcher;

interface EventDispatcherInterface
{
    public function dispatch(object $event): object;
}

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

interface StoppableEventInterface
{
    public function isPropagationStopped(): bool;
}
Enter fullscreen mode Exit fullscreen mode

Notice what an event is: object. No base class, no interface to implement, no marker. Any PHP object is a valid event. That is the whole point. Your OrderPlaced stays a pure domain object with zero framework surface.

The dispatcher does one thing: hand it an event, it runs the listeners and hands the same event back. The listener provider answers a separate question: given this event, which callables care about it? Splitting those two jobs is the design decision that makes the standard portable.

Why the split matters

Framework event systems fuse dispatch and routing into one god object. PSR-14 keeps them apart on purpose.

  • The dispatcher owns the loop: iterate listeners, call each one, check for stopped propagation, return the event.
  • The provider owns the mapping: event type in, listeners out. Nothing about how they run.

That seam is where the flexibility lives. You can swap a provider that reads a static array for one that reads compiled Symfony container tags, or one that resolves listeners lazily from a PSR-11 container, and the dispatcher never changes. Your domain calls dispatch() and stays ignorant of all of it.

A dispatcher you could ship today

The reference dispatcher is about fifteen lines. Here it is, handling stoppable events correctly.

<?php

declare(strict_types=1);

namespace App\Events;

use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Psr\EventDispatcher\StoppableEventInterface;

final readonly class Dispatcher implements EventDispatcherInterface
{
    public function __construct(
        private ListenerProviderInterface $provider,
    ) {}

    public function dispatch(object $event): object
    {
        $stoppable = $event instanceof StoppableEventInterface;

        foreach ($this->provider->getListenersForEvent($event) as $listener) {
            if ($stoppable && $event->isPropagationStopped()) {
                break;
            }
            $listener($event);
        }

        return $event;
    }
}
Enter fullscreen mode Exit fullscreen mode

A listener is any callable. It receives the event by reference-of-object, so a listener that mutates the event (adds a validation error, fills a response) changes what later listeners and the caller see. That is how PSR-14 replaces the old "return a modified payload" pattern: you mutate the event object instead.

The stoppable check reads isPropagationStopped() before each listener, so a listener that stops propagation halts the ones queued behind it. You use this for things like a security event where the first listener that denies access should short-circuit the rest.

The listener provider

Here is a provider that maps concrete event classes to listeners, including parents and interfaces so a listener bound to an interface fires for every event that implements it.

<?php

declare(strict_types=1);

namespace App\Events;

use Psr\EventDispatcher\ListenerProviderInterface;

final class TypeListenerProvider implements ListenerProviderInterface
{
    /** @var array<class-string, list<callable>> */
    private array $listeners = [];

    public function on(string $eventType, callable $listener): void
    {
        $this->listeners[$eventType][] = $listener;
    }

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

Register a listener against OrderPlaced and only order-placed events reach it. Register one against a DomainEvent interface and it hears everything. The provider is the only place that knows the wiring, and it is plain PHP you can unit-test without booting a kernel.

The domain event stays pure

This is the payoff. OrderPlaced imports nothing from any framework and nothing from PSR either.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;
use DateTimeImmutable;

final readonly class OrderPlaced
{
    public function __construct(
        public OrderId $orderId,
        public CustomerId $customerId,
        public int $totalCents,
        public DateTimeImmutable $occurredAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

No extends Event, no ShouldBroadcast, no attributes. It is a value object that records a fact. The application layer collects these off the aggregate and dispatches them after the transaction commits.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\OrderRepository;
use Psr\EventDispatcher\EventDispatcherInterface;

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

    public function execute(PlaceOrderInput $in): void
    {
        $order = Order::place(/* ... */);
        $this->orders->save($order);

        foreach ($order->releaseEvents() as $event) {
            $this->dispatcher->dispatch($event);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The use case depends on Psr\EventDispatcher\EventDispatcherInterface, not on any framework. Swap the concrete dispatcher underneath and this file does not move.

Wiring it in Symfony

Symfony's event dispatcher already implements the PSR-14 interfaces. Since version 4.3 the component ships a PSR-14 bridge, so Symfony\Component\EventDispatcher\EventDispatcher is a valid Psr\EventDispatcher\EventDispatcherInterface. You type-hint the PSR interface and autowiring gives you Symfony's dispatcher.

# config/services.yaml
services:
    Psr\EventDispatcher\EventDispatcherInterface:
        '@event_dispatcher'
Enter fullscreen mode Exit fullscreen mode

Your listeners register the normal Symfony way, tagged kernel.event_listener or auto-registered from an AsEventListener attribute. The difference is that your domain now depends on the PSR interface, and Symfony is just the adapter satisfying it. Nothing in App\Domain or App\Application knows Symfony exists.

Wiring it in Laravel

Laravel does not implement PSR-14 out of the box, so you bind the reference dispatcher yourself in a service provider. Ten lines, one time.

<?php

declare(strict_types=1);

namespace App\Providers;

use App\Events\Dispatcher;
use App\Events\TypeListenerProvider;
use App\Domain\Order\OrderPlaced;
use App\Listeners\SendInvoice;
use Illuminate\Support\ServiceProvider;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;

final class DomainEventServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(
            ListenerProviderInterface::class,
            function ($app): TypeListenerProvider {
                $provider = new TypeListenerProvider();
                $provider->on(
                    OrderPlaced::class,
                    fn ($e) => $app->make(SendInvoice::class)($e),
                );
                return $provider;
            },
        );

        $this->app->singleton(
            EventDispatcherInterface::class,
            fn ($app) => new Dispatcher(
                $app->make(ListenerProviderInterface::class),
            ),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now PlaceOrder gets a PSR-14 dispatcher through the container, same as it would in Symfony. The listener closure resolves SendInvoice from Laravel's container lazily, so listeners keep their normal dependency injection. Laravel's own event() helper still works for framework events; your domain events run through the PSR path.

The framework is the outer ring. It satisfies an interface your domain declared. When you move the domain code between the two apps, the event layer travels with it untouched, because the only import it carries is Psr\EventDispatcher.

What you give up, and what you don't

PSR-14 is deliberately minimal. It does not define queued or async listeners, event subclass ordering, or a wildcard syntax. Those are framework concerns, and both Laravel and Symfony still offer them on their side of the adapter. What the standard guarantees is the contract at the boundary: an object goes in, listeners run, the object comes back.

That contract is enough to keep the one layer that encodes your business rules free of framework imports. Your OrderPlaced is a fact about your domain, not a subclass of somebody's base Event.

Keeping the dispatch contract at the edge and the events themselves as pure domain objects is exactly the kind of seam Decoupled PHP is built around: the framework satisfies an interface your application declared, never the other way round. Do this with events, repositories, and clocks alike, and the code that describes your business outlives every framework upgrade you will live through.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)