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 compatible —
EventDispatcherInterface,ListenerProviderInterfaceandStoppableEventInterfacefully 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
composer require jardisadapter/eventdispatcherGitHub: jardisAdapter/eventdispatcher
Basic Usage
Registering Listeners and Dispatching Events
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:
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:
$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:
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:
$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:
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.
$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 dispatchingFluent API
$collector
->record(new OrderCreated($orderId))
->record(new PaymentReceived($paymentId))
->record(new InventoryReserved($itemId))
->dispatchAll($dispatcher);Removing Listeners
$listener = function (OrderCreated $event): void { /* ... */ };
$provider->listen(OrderCreated::class, $listener);
$provider->remove(OrderCreated::class, $listener); // Safe: no-op if not registeredError Handling
The package defines no custom exceptions. Errors in listeners propagate unchanged to the caller:
| Situation | Behavior |
|---|---|
| Listener throws exception | Propagates unchanged to caller |
| No listener registered | Event is silently ignored |
| Event already stopped | No 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 objectDesign Decisions
| Decision | Rationale |
|---|---|
No EventSubscriberInterface | Static subscribers violate the principle of explicit dependencies |
| No async dispatch | Synchronous by design. For cross-process events: jardisadapter/messaging |
| No Event Store/Sourcing | Persistence is infrastructure responsibility |
| No listener discovery | No classpath scanning, no reflection. Registration is explicit |
API Reference
EventDispatcher
| Method | Signature | Description |
|---|---|---|
__construct | __construct(ListenerProviderInterface $provider) | Inject PSR-14 provider |
dispatch | dispatch(object $event): object | Dispatch event, call listeners |
ListenerProvider
| Method | Signature | Description |
|---|---|---|
listen | listen(string $eventClass, callable $listener, int $priority = 0): void | Register listener |
remove | remove(string $eventClass, callable $listener): void | Remove listener |
getListenersForEvent | getListenersForEvent(object $event): iterable | Get listeners for event (sorted) |
Event
| Method | Signature | Description |
|---|---|---|
isPropagationStopped | isPropagationStopped(): bool | Is propagation stopped? |
stopPropagation | stopPropagation(): void | Stop propagation |
EventCollector
| Method | Signature | Description |
|---|---|---|
record | record(object $event): self | Record event |
dispatchAll | dispatchAll(EventDispatcherInterface $dispatcher): self | Dispatch all events + clear |
events | events(): array | Inspect collected events |
clear | clear(): self | Discard events |
count | count(): int | Number of collected events |
Complete Example
A typical DDD setup with domain events, prioritized listeners, type-hierarchy matching and EventCollector:
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);