Skip to content

Event Dispatcher

Four classes, zero magic: synchronous event dispatching for Domain-Driven Design.

Introduction

Domain Events are the backbone of a clean DDD architecture: "An order was placed", "A password was changed", "A stock item was reserved." For these events to arrive reliably, you need a dispatcher, but not one that brings half a framework along with it.

jardisadapter/eventdispatcher is deliberately minimalist. Four classes, fully PSR-14 compatible, without annotations, without subscriber pattern, without auto-discovery:

  • PSR-14 compatibleEventDispatcherInterface, ListenerProviderInterface and StoppableEventInterface fully implemented
  • Priority ordering — higher priority = earlier execution. Direct and wildcard listeners are sorted together
  • Type-hierarchy matching — a listener on an interface catches all events that implement it
  • Stoppable events — any event can stop the listener chain
  • EventCollector — the DDD pattern: collect events in the domain, dispatch in the application layer
  • Explicit registration — no classpath scanning, no reflection, no implicit dependencies

Installation

bash
composer require jardisadapter/eventdispatcher

GitHub: jardisAdapter/eventdispatcher

Basic Usage

Registering Listeners and Dispatching Events

php
use JardisAdapter\EventDispatcher\EventDispatcher;
use JardisAdapter\EventDispatcher\ListenerProvider;

$provider = new ListenerProvider();

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // Send email confirmation
});

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // Update inventory
});

$dispatcher = new EventDispatcher($provider);

// Dispatch event — all registered listeners are called
$dispatcher->dispatch(new OrderCreated($orderId));

Defining Your Own Domain Events

Events are simple PHP objects. The optional base class Event adds stoppable functionality:

php
use JardisAdapter\EventDispatcher\Event;

final class OrderCreated extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly float $totalAmount,
    ) {}
}

Without the base class, any object works as an event, but then without stopPropagation().

Priorities

Listeners can be assigned priorities. Higher number = earlier execution:

php
$provider->listen(OrderCreated::class, $sendConfirmation, priority: 10);  // first
$provider->listen(OrderCreated::class, $updateInventory,  priority: 5);   // then
$provider->listen(OrderCreated::class, $logEvent);                        // last (0)

Negative priorities are allowed: useful for "cleanup" listeners that should always run at the end.

Type-Hierarchy Matching

A listener registered on an interface or base class catches all events that implement or extend that type:

php
interface PaymentEventInterface {}

final class PaymentReceived extends Event implements PaymentEventInterface
{
    public function __construct(public readonly string $paymentId) {}
}

final class PaymentFailed extends Event implements PaymentEventInterface
{
    public function __construct(public readonly string $paymentId, public readonly string $reason) {}
}

// Catches BOTH events — PaymentReceived and PaymentFailed
$provider->listen(PaymentEventInterface::class, function (PaymentEventInterface $event): void {
    // Audit log for all payment events
});

// Catches EVERY event that extends Event
$provider->listen(Event::class, function (Event $event): void {
    // Global logger
});

Direct and wildcard listeners are sorted together by priority. An interface listener with priority 10 is called before a direct listener with priority 5.

Stoppable Events

A listener can stop processing. Subsequent listeners are no longer called:

php
$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    if ($event->totalAmount > 10000) {
        // Fraud check blocks the order
        $event->stopPropagation();
    }
}, priority: 100);  // High priority: runs first

$provider->listen(OrderCreated::class, function (OrderCreated $event): void {
    // Will NOT be called if propagation was stopped
    sendConfirmationEmail($event);
}, priority: 0);

Already stopped events

If an event is dispatched that is already stopped (isPropagationStopped() === true), no listeners are called.

EventCollector — Deferred Dispatch

In a DDD architecture, domain objects should record events without having a dependency on the dispatcher. The EventCollector solves this:

php
use JardisAdapter\EventDispatcher\EventCollector;

// Domain layer — no dispatcher needed
$collector = new EventCollector();
$collector->record(new OrderCreated($orderId));
$collector->record(new InventoryReserved($itemId));

// Application layer — after the use case logic
$collector->dispatchAll($dispatcher);

Safe Dispatch Behavior

dispatchAll() clears the internal list before dispatching. Events recorded by listeners during dispatching land in the collector, but not in the current dispatchAll() run. This prevents cascading events from being processed endlessly.

php
$collector->record($event1);
$collector->record($event2);

// Dispatches $event1 and $event2. The collector is empty afterwards.
$collector->dispatchAll($dispatcher);

// Utility methods
$collector->count();     // Number of collected events
$collector->events();    // Inspect events without dispatching
$collector->clear();     // Discard events without dispatching

Fluent API

php
$collector
    ->record(new OrderCreated($orderId))
    ->record(new PaymentReceived($paymentId))
    ->record(new InventoryReserved($itemId))
    ->dispatchAll($dispatcher);

Removing Listeners

php
$listener = function (OrderCreated $event): void { /* ... */ };

$provider->listen(OrderCreated::class, $listener);
$provider->remove(OrderCreated::class, $listener);  // Safe: no-op if not registered

Error Handling

The package defines no custom exceptions. Errors in listeners propagate unchanged to the caller:

SituationBehavior
Listener throws exceptionPropagates unchanged to caller
No listener registeredEvent is silently ignored
Event already stoppedNo listeners are called

Architecture

The package follows the Closure-Orchestrator-Pattern in its most minimalist form, four classes, clear responsibilities:

EventDispatcher                    ← Orchestrator (PSR-14)
├── ListenerProvider               ← Registry + matcher
│   └── [callable, priority][]     ← Listener registration
├── Event                          ← Abstract base (optional)
└── EventCollector                 ← Deferred dispatch (DDD)

Directory Structure

src/
├── EventDispatcher.php      ← Orchestrator (PSR-14 EventDispatcherInterface)
├── ListenerProvider.php     ← Registry (PSR-14 ListenerProviderInterface)
├── Event.php                ← Abstract base (StoppableEventInterface)
└── EventCollector.php       ← Deferred dispatch value object

Design Decisions

DecisionRationale
No EventSubscriberInterfaceStatic subscribers violate the principle of explicit dependencies
No async dispatchSynchronous by design. For cross-process events: jardisadapter/messaging
No Event Store/SourcingPersistence is infrastructure responsibility
No listener discoveryNo classpath scanning, no reflection. Registration is explicit

API Reference

EventDispatcher

MethodSignatureDescription
__construct__construct(ListenerProviderInterface $provider)Inject PSR-14 provider
dispatchdispatch(object $event): objectDispatch event, call listeners

ListenerProvider

MethodSignatureDescription
listenlisten(string $eventClass, callable $listener, int $priority = 0): voidRegister listener
removeremove(string $eventClass, callable $listener): voidRemove listener
getListenersForEventgetListenersForEvent(object $event): iterableGet listeners for event (sorted)

Event

MethodSignatureDescription
isPropagationStoppedisPropagationStopped(): boolIs propagation stopped?
stopPropagationstopPropagation(): voidStop propagation

EventCollector

MethodSignatureDescription
recordrecord(object $event): selfRecord event
dispatchAlldispatchAll(EventDispatcherInterface $dispatcher): selfDispatch all events + clear
eventsevents(): arrayInspect collected events
clearclear(): selfDiscard events
countcount(): intNumber of collected events

Complete Example

A typical DDD setup with domain events, prioritized listeners, type-hierarchy matching and EventCollector:

php
use JardisAdapter\EventDispatcher\Event;
use JardisAdapter\EventDispatcher\EventCollector;
use JardisAdapter\EventDispatcher\EventDispatcher;
use JardisAdapter\EventDispatcher\ListenerProvider;

// Define domain events
final class OrderPlaced extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly float $total,
    ) {}
}

final class PaymentProcessed extends Event
{
    public function __construct(
        public readonly string $orderId,
        public readonly string $transactionId,
    ) {}
}

// Configure listener provider
$provider = new ListenerProvider();

// Global audit logger for ALL events (low priority)
$provider->listen(Event::class, function (Event $event): void {
    $logger->info('Event dispatched', ['event' => get_class($event)]);
}, priority: -10);

// Fraud check with highest priority
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    if ($event->total > 10000) {
        $event->stopPropagation();  // Blocks all subsequent listeners
        throw new FraudCheckException($event->orderId);
    }
}, priority: 100);

// Send confirmation
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    $mailer->sendOrderConfirmation($event->orderId);
}, priority: 10);

// Reserve inventory
$provider->listen(OrderPlaced::class, function (OrderPlaced $event): void {
    $inventory->reserve($event->orderId);
}, priority: 5);

// Create dispatcher
$dispatcher = new EventDispatcher($provider);

// In use case: collect events and dispatch in bulk
$collector = new EventCollector();
$collector->record(new OrderPlaced('ORD-001', 299.99));
$collector->record(new PaymentProcessed('ORD-001', 'TXN-abc123'));

// Dispatch everything at once — after successful persistence
$collector->dispatchAll($dispatcher);