Pub/Sub
Publish/Subscribe is the loosely-coupled fan-out pattern. Publishers emit events to a topic; subscribers receive them. Unlike Observer (in-process direct calls), pub/sub usually crosses process or network boundaries — Kafka, NATS, Redis Streams, SNS+SQS, Cloud Pub/Sub.
In-process pub/sub plus a Redis Streams worker
EXAMPLE
<?php
// ============================================================
// 1) In-process pub/sub (synchronous)
// ============================================================
class Topic {
/** @var callable[] */
private array $subscribers = [];
public function subscribe(callable $handler): void {
$this->subscribers[] = $handler;
}
public function publish(array $event): void {
foreach ($this->subscribers as $h) {
try { $h($event); }
catch (Throwable $e) {
error_log('handler failed: ' . $e->getMessage());
// one handler failure should NOT block siblings
}
}
}
}
$orderEvents = new Topic();
// Subscribers
$orderEvents->subscribe(function (array $event) {
if ($event['type'] === 'OrderPaid') {
echo "send receipt for order {$event['orderId']}
";
}
});
$orderEvents->subscribe(function (array $event) {
if ($event['type'] === 'OrderPaid') {
echo "increment analytics for {$event['orderId']}
";
}
});
// Publish
$orderEvents->publish([ 'type' => 'OrderPaid', 'orderId' => 'o1' ]);
// ============================================================
// 2) Synchronous vs asynchronous
// ============================================================
// Synchronous in-process pub/sub blocks the publisher until all handlers run.
// For slow side effects (HTTP, email), push to a queue and run async.
// ============================================================
// 3) Redis Streams — at-least-once durable fan-out
// ============================================================
// Producer
$r = new Redis();
$r->connect('127.0.0.1', 6379);
$r->xAdd('orders.events', '*', [
'type' => 'OrderPaid', 'orderId' => 'o1', 'amount' => '4995',
]);
// One-time setup of consumer group
try { $r->xGroup('CREATE', 'orders.events', 'analytics', '$', true); } catch (Throwable $e) {}
try { $r->xGroup('CREATE', 'orders.events', 'mailer', '$', true); } catch (Throwable $e) {}
// Worker — analytics consumer
function run_worker(string $group, string $consumer): void {
$r = new Redis(); $r->connect('127.0.0.1', 6379);
while (true) {
$entries = $r->xReadGroup($group, $consumer, [ 'orders.events' => '>' ], 16, 5000);
if (!$entries) continue;
foreach ($entries['orders.events'] as $id => $fields) {
try {
handle_event($fields);
$r->xAck('orders.events', $group, [$id]);
} catch (Throwable $e) {
// leave unacked -> XPENDING will redeliver after timeout
error_log('handler failed: ' . $e->getMessage());
}
}
}
}
function handle_event(array $fields): void {
// do the work
}
// ============================================================
// 4) Patterns to internalise
// ============================================================
// - Publishers KNOW NOTHING about subscribers
// - Each subscriber has its own retry / failure policy
// - Use durable streams (Kafka, Streams) for inter-service events
// - In-process pub/sub for cross-cutting side effects
// - Idempotency on the consumer side (events may be redelivered)
// - Schema versioning: events are forever; design the payload conservatively
// ============================================================
// 5) Decision matrix
// ============================================================
// - Same process, lightweight side effects -> in-process Topic
// - Cross-service, durable fan-out -> Kafka / Redis Streams / SNS+SQS
// - Real-time browser updates -> WebSockets / Server-Sent Events
// - 'I need exactly-once' -> hard. Use idempotency keys + at-least-once.
// - 'I want to query past events' -> Kafka with infinite retention, or an outbox table
// ============================================================
// 6) Pitfalls
// ============================================================
// - In-process pub/sub for slow handlers -> blocks the request
// - No idempotency on consumers -> double-charges, double-emails
// - Publishing inside a transaction that may roll back -> phantom events
// (use the outbox pattern: insert event row in the same tx; worker reads + publishes)
// - Schema changes that break consumers -> version events; never repurpose fields
Why it matters
Treat pub/sub events as a public API: schema-stable, versioned, idempotent on the consumer side. Once you publish "OrderPaid v1", every consumer assumes that shape forever. Plan for v2 + parallel publishing; never repurpose a field. The pattern that scales is "events are forever; consumers are not".
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Async, often cross-process. Publishers and subscribers don't know each other. // Real-world: Redis pub/sub, NATS, Kafka, MQTT, Postgres LISTEN/NOTIFY.Try it Yourself »
Discussion
Loading…