iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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…