Building Event-Driven Workflows
Coordinate services through events and idempotency.
Building Event-Driven Workflows is a free PHP Academy lesson on CoddyKit — lesson 4 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Event-Driven Workflows
A single message is easy. A workflow — "order placed → reserve stock → charge card → ship → notify" spanning several services — is where event-driven design earns its keep, and where it bites you if done naively.
This lesson covers choreography vs orchestration, the outbox pattern for atomic publishing, sagas for distributed rollback, and the idempotency that holds it all together.
Choreography vs Orchestration
Two ways to coordinate multi-step flows:
- Choreography — each service reacts to events and emits its own; no central brain. Loosely coupled but the overall flow is implicit and hard to trace.
- Orchestration — a central coordinator tells each service what to do next. Explicit and observable, but the orchestrator is a coupling point.
Rule of thumb: choreography for simple fan-out, orchestration when a flow has many ordered steps and needs visible state.
Events vs Commands
Name your messages deliberately:
- An event states a fact in the past:
OrderPlaced. The publisher doesn't care who listens. - A command requests a future action at a specific handler:
ChargeCard.
Events drive choreography; commands drive orchestration. Mixing the vocabulary (an "event" that secretly expects one handler) is a common source of hidden coupling.
<?php
final class OrderPlaced {
public function __construct(
public readonly string $orderId,
public readonly string $customerId,
public readonly int $amountCents,
public readonly string $occurredAt,
) {}
}
$e = new OrderPlaced('o-42', 'c-7', 1990, gmdate('c'));
echo json_encode($e), "\n";The Dual-Write Problem
The classic bug: a handler updates the database and publishes a message as two separate operations. If the process dies between them, you get an inconsistency — the row changed but no event was sent, or vice versa.
<?php
// BROKEN: not atomic. A crash between the two lines corrupts state.
function placeOrder(PDO $db, $broker, array $o): void {
$db->prepare('INSERT INTO orders ...')->execute($o);
// <-- crash here = row exists but no event ever published
$broker->publish('OrderPlaced', json_encode($o));
}The Transactional Outbox
The fix is the outbox pattern: within the same DB transaction that changes your data, insert the event into an outbox table. A separate relay process reads unpublished rows and ships them to the broker. One atomic commit, no dual write.
<?php
function placeOrder(PDO $db, array $o): void {
$db->beginTransaction();
$db->prepare('INSERT INTO orders (id, total) VALUES (?, ?)')
->execute([$o['id'], $o['total']]);
// Same transaction -> atomic with the business write
$db->prepare('INSERT INTO outbox (id, type, payload) VALUES (?, ?, ?)')
->execute([bin2hex(random_bytes(8)), 'OrderPlaced', json_encode($o)]);
$db->commit();
}The Relay (Publisher)
A worker polls the outbox (or tails the DB change log via CDC), publishes each row, then marks it sent. Because the relay can crash after publishing but before marking, it is itself at-least-once — which is fine, since consumers are idempotent.
<?php
function relayOutbox(PDO $db, $broker): void {
$rows = $db->query(
'SELECT id, type, payload FROM outbox
WHERE published_at IS NULL ORDER BY created_at LIMIT 100'
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
$broker->publish($r['type'], $r['payload'], messageId: $r['id']);
$db->prepare('UPDATE outbox SET published_at = now() WHERE id = ?')
->execute([$r['id']]);
}
}Idempotent Consumers (Again)
Because both the relay and the broker are at-least-once, downstream handlers will see duplicates. Each consumer records the message id it has processed and short-circuits repeats — the same dedup gate you learned earlier, now applied per service.
<?php
function onOrderPlaced(PDO $db, string $messageId, array $data): void {
$db->beginTransaction();
try {
$db->prepare('INSERT INTO inbox (message_id) VALUES (?)')
->execute([$messageId]); // unique index = dedup
} catch (PDOException $e) {
$db->rollBack();
return; // already handled this message
}
reserveStock($data['orderId']);
$db->commit();
}
function reserveStock(string $id): void {}Sagas: Distributed Rollback
You can't open one ACID transaction across services. A saga models a long-running flow as a sequence of local transactions, each with a compensating action that undoes it. If step 3 fails, you run the compensations for steps 2 and 1 in reverse.
Example: payment fails after stock was reserved → emit ReleaseStock to compensate. There is no automatic rollback — you design the undo for every step.
An Orchestrated Saga
An orchestrator drives the saga: it advances on success and dispatches compensations on failure. Persist the saga's state so a crash can resume it.
<?php
function handleStepResult(array $saga, string $step, bool $ok, $bus): array {
if ($ok) {
$next = ['reserveStock' => 'chargeCard', 'chargeCard' => 'ship'][$step] ?? null;
if ($next) { $bus->send($next, $saga['orderId']); $saga['state'] = $next; }
else { $saga['state'] = 'completed'; }
} else {
// Run compensations in reverse for whatever already succeeded
foreach (array_reverse($saga['done']) as $s) {
$bus->send('compensate.' . $s, $saga['orderId']);
}
$saga['state'] = 'compensating';
}
return $saga;
}Timeouts in Long Flows
A saga step can simply never report back — the payment service is down, a human approval never comes. Without a timeout, the saga hangs forever holding reservations. Persist a deadline per step; a scheduler scans for overdue sagas and triggers the failure/compensation path.
<?php
function reapTimedOutSagas(PDO $db, $bus): void {
$rows = $db->query(
"SELECT order_id, state FROM sagas
WHERE state NOT IN ('completed','compensating')
AND deadline_at < now()"
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $r) {
echo "Saga {$r['order_id']} timed out at step {$r['state']}\n";
$bus->send('saga.compensate', $r['order_id']); // trigger rollback
}
}Versioning & Observability
Workflows live for years; events must evolve safely:
- Add a
version(or schema) to every event; consumers tolerate unknown new fields and never assume field presence. - Prefer additive changes; never repurpose an existing field's meaning.
- Propagate a correlation id through every message so you can trace one business transaction across all services in your logs/tracing.
Without correlation ids, debugging a choreographed flow across five services is nearly impossible.
<?php
$envelope = [
'type' => 'OrderPlaced',
'version' => 2,
'correlationId' => $incoming['correlationId'] ?? bin2hex(random_bytes(8)),
'occurredAt' => gmdate('c'),
'data' => ['orderId' => 'o-42'],
];
echo json_encode($envelope, JSON_PRETTY_PRINT), "\n";Quick Check
Avoiding the dual-write problem.
Recap
You can now design reliable event-driven workflows:
- Choose choreography (events) or orchestration (commands) per flow complexity.
- Solve dual-write with the transactional outbox + a relay.
- Make every consumer idempotent via an inbox/dedup key.
- Use sagas with compensating actions for distributed rollback.
- Version events additively and thread a correlation id for traceability.
These patterns turn loose messages into dependable, observable business processes.
Frequently asked questions
Is the “Building Event-Driven Workflows” lesson free?
Yes — the full text of “Building Event-Driven Workflows” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “Building Event-Driven Workflows”?
Coordinate services through events and idempotency. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 4 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Building Event-Driven Workflows” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Why Asynchronous Messaging
- Working with RabbitMQ in PHP
- Apache Kafka with PHP
- Building Event-Driven Workflows