Event Bus
An event bus is a publish/subscribe channel that decouples emitters from listeners. Components fire events ("OrderPaid") without knowing who handles them; handlers subscribe by event type. Use it for cross-cutting concerns (audit, email, analytics) where direct method calls would tangle the design.
A typed event bus with sync + async handlers
EXAMPLE
<?php
// ============================================================
// Event types
// ============================================================
abstract class DomainEvent {
public readonly DateTimeImmutable $at;
public function __construct() { $this->at = new DateTimeImmutable(); }
}
final class OrderPaid extends DomainEvent {
public function __construct(public string $orderId, public int $amountCents) { parent::__construct(); }
}
final class OrderShipped extends DomainEvent {
public function __construct(public string $orderId, public string $tracking) { parent::__construct(); }
}
// ============================================================
// Handler contract
// ============================================================
interface EventHandler {
/** @return class-string<DomainEvent>[] */
public function handles(): array;
public function handle(DomainEvent $event): void;
}
// ============================================================
// The bus
// ============================================================
final class EventBus {
/** @var array<class-string<DomainEvent>, EventHandler[]> */
private array $subs = [];
public function subscribe(EventHandler $h): void {
foreach ($h->handles() as $cls) {
$this->subs[$cls] ??= [];
$this->subs[$cls][] = $h;
}
}
public function publish(DomainEvent $event): void {
$cls = $event::class;
foreach ($this->subs[$cls] ?? [] as $h) {
try {
$h->handle($event);
} catch (Throwable $e) {
// Default: log + continue. A handler failure should NOT
// block sibling handlers from running.
error_log('handler ' . $h::class . ' failed: ' . $e->getMessage());
}
}
}
}
// ============================================================
// Concrete handlers
// ============================================================
final class SendReceiptEmail implements EventHandler {
public function __construct(private Mailer $mailer) {}
public function handles(): array { return [OrderPaid::class]; }
public function handle(DomainEvent $event): void {
/** @var OrderPaid $event */
$this->mailer->send('order ' . $event->orderId . ' paid');
}
}
final class WriteToAuditLog implements EventHandler {
public function __construct(private AuditLog $audit) {}
public function handles(): array { return [OrderPaid::class, OrderShipped::class]; }
public function handle(DomainEvent $event): void {
$this->audit->write($event::class, get_object_vars($event));
}
}
final class TrackToAnalytics implements EventHandler {
public function __construct(private Analytics $analytics) {}
public function handles(): array { return [OrderPaid::class]; }
public function handle(DomainEvent $event): void {
$this->analytics->track('order_paid', ['id' => $event->orderId, 'amount' => $event->amountCents]);
}
}
// ============================================================
// Wiring + use
// ============================================================
$bus = new EventBus();
$bus->subscribe(new SendReceiptEmail(new Mailer()));
$bus->subscribe(new WriteToAuditLog(new AuditLog()));
$bus->subscribe(new TrackToAnalytics(new Analytics()));
// In the domain code
function payOrder(EventBus $bus, string $orderId, int $amountCents): void {
// ... update DB ...
$bus->publish(new OrderPaid($orderId, $amountCents));
}
payOrder($bus, 'o1', 4995);
// ============================================================
// Async handlers (queue-backed)
// ============================================================
// Sync handlers run in the request thread. For slow side effects (HTTP,
// email), push to a queue and run async:
final class AsyncEnqueue implements EventHandler {
public function __construct(private Queue $queue, private string $queueName) {}
public function handles(): array { return [OrderPaid::class, OrderShipped::class]; }
public function handle(DomainEvent $event): void {
$this->queue->push($this->queueName, [
'event' => $event::class,
'data' => get_object_vars($event),
]);
}
}
// ============================================================
// Anti-patterns
// ============================================================
// 1) Listener that throws unhandled — silently breaks side effects
// Fix: log + continue (above); OR a dead-letter strategy for async handlers
//
// 2) Synchronous handler doing slow IO (HTTP, big DB write)
// Fix: emit the event; queue the work
//
// 3) Listener that reads or mutates DOMAIN state directly
// Fix: the event carries everything the handler needs (no late binding)
//
// 4) Implicit ordering between handlers
// The bus does not promise an order. If you need order, use a chain pattern
//
// 5) Cross-process broadcast via an in-memory bus
// Use a real message broker (NATS, RabbitMQ, Redis Streams) for that
// ============================================================
// When to use an event bus
// ============================================================
// - Cross-cutting concerns (analytics, audit, notifications)
// - Decoupling modules that should not know about each other
// - Plugin-style architectures where new handlers come from extensions
// - 'Reactive' side effects that should run on a state change
//
// When NOT:
// - Tightly-coupled call chains where direct method calls are clearer
// - Performance-critical hot paths
// - Strong-ordering or transactional guarantees (use a real workflow engine)
// Stubs
class Mailer { public function send(string $msg): void {} }
class AuditLog { public function write(string $type, array $data): void {} }
class Analytics { public function track(string $name, array $props): void {} }
class Queue { public function push(string $name, array $payload): void {} }
Why it matters
An event bus is the right shape when "X happened, and N teams want to react". Domain code stays focused, new handlers ship in their own PR, and removing a handler is one line. Resist the urge to use it everywhere — direct calls are still clearer when the handler is one specific piece of business logic.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Replace tight coupling with named events.
bus.emit('user.created', user);
bus.on('user.created', sendWelcomeEmail);
bus.on('user.created', enrollInOnboarding);
Try it Yourself »
Discussion
Loading…