The bug that finally convinces a team to build an outbox is never the one they expected, because it is not the message that failed to send; it is the message that sent for an order that never committed, or the order that committed while its confirmation email vanished into a crashed worker, and either way someone in support is reconciling two systems that each believe a different version of Tuesday. The root cause is always the same shape, the dual write: your application commits a row to the database and then publishes an event to a broker, an email API, or a webhook, and those are two separate systems with two separate commit points, so there is no interleaving of crashes, retries, and timeouts that cannot strand one side of the pair. The transactional outbox is the standard answer, where the event becomes a row in the same database, written in the same transaction as the domain change, and a relay publishes from that table afterward; the reason most teams put it off is that the pattern has historically come with a crew, because the usual implementation is Debezium tailing a WAL into Kafka, which is excellent engineering that also happens to be two distributed systems you did not previously have to operate. The pitch here is narrower and more useful for a PHP shop: when your database already commits multi-table batches atomically and dedupes retried commits at the storage layer, the outbox pattern collapses into a table, one batch commit, and a relay loop you can read in one sitting, and the hard parts, atomicity and double-processing, are the engine’s problem rather than yours.

Why the naive version fails twice

The tempting shortcut is to send the message right after commit() returns, and the reason it fails is that it actually fails twice, once in each direction. Crash after the commit but before the publish and the event is lost forever, because nothing on disk remembers you owed the world a message; crash after the publish but before the commit and you have announced an order that does not exist, which is worse, because now an external system holds a lie you cannot retract. Retrying makes the second failure more common instead of less, since a timeout after a successful publish followed by a successful retry publishes the same event twice, and at that point you have built at-least-once delivery by accident, with no dedupe on the far side to absorb it. Moving the write into the database first, into an outbox table that commits atomically with the domain change, fixes the direction problem completely, because the event can never exist without the order or the order without the event, and what remains is the relay’s problem of publishing exactly that table, which is a much smaller problem with much better tooling for getting it right.

The table, and the commit that makes it atomic

The outbox table is deliberately boring, because every clever thing you might add to it is a failure mode you will meet later; it needs the event payload, a routing hint, a published marker, and an attempt count, and MongrelDB’s typed schema makes the shape explicit:

$db->createTable('outbox', [
    'columns' => [
        ['name' => 'id',           'type' => 'TEXT',      'primary_key' => true],
        ['name' => 'topic',        'type' => 'TEXT',      'not_null' => true],
        ['name' => 'payload',      'type' => 'JSON',      'not_null' => true],
        ['name' => 'created_at',   'type' => 'TIMESTAMP', 'not_null' => true],
        ['name' => 'published_at', 'type' => 'TIMESTAMP', 'not_null' => false],
        ['name' => 'attempts',     'type' => 'INT64',     'not_null' => true],
    ],
]);

The write path is where the pattern earns its name, because the order row and the outbox row go out in one batch and commit as one transaction, so there is no window, not even a microsecond wide, in which one exists without the other:

$eventId = "evt-order-{$orderId}";

$txn = $db->beginTransaction();
$txn->put('orders', ['id' => $orderId, 'total' => 9900, 'status' => 'paid']);
$txn->put('outbox', [
    'id'           => $eventId,
    'topic'        => 'order.paid',
    'payload'      => ['order_id' => $orderId, 'total' => 9900],
    'created_at'   => $now,
    'published_at' => null,
    'attempts'     => 0,
]);
$txn->commit(idempotencyKey: "order-{$orderId}-paid");

The idempotency key on that commit is not decorative, because the write path has the same retry problem as everything else; if the POST carrying this batch times out, the application re-sends the identical batch under the identical key, and the engine recognizes the retry and replays the original receipt instead of re-executing, so the pair of rows lands exactly once even when the network never told you it landed at all. Derive the key from something the domain already owns, the order number here, rather than minting a random one per attempt, because a fresh key per retry is a fresh execution per retry and you have defeated the mechanism you asked for.

The relay in a hundred lines

The relay is a loop with three responsibilities: find unpublished rows, hand each one to the outside world, and mark it published, and the only subtle part is that the mark and the publish are themselves a dual write, just a smaller one, because you cannot atomically commit a database row and an SMTP call. The resolution is to accept at-least-once at this hop and make both halves safe to repeat; the publish is idempotent because the event carries its stable id and any competent downstream dedupes on it, and the mark-published write is idempotent because the commit is keyed on the event id:

while (true) {
    $pending = $db->query('outbox')
        ->where('range', ['column' => 'published_at', 'op' => 'is_null'])
        ->orderBy('created_at')
        ->limit(50)
        ->execute();

    if (!$pending) {
        sleep(2);
        continue;
    }

    foreach ($pending as $row) {
        try {
            $publisher->send($row['topic'], $row['id'], $row['payload']);

            $db->beginTransaction()
                ->put('outbox', array_merge($row, [
                    'published_at' => now(),
                    'attempts'     => $row['attempts'] + 1,
                ]))
                ->commit(idempotencyKey: "publish-{$row['id']}");
        } catch (Throwable $e) {
            // Leave it unpublished; attempts bound the retries, and a
            // poison event parks for a human rather than looping forever.
        }
    }
}

Walk the failure modes and the design holds up, which is the test that matters for a reliability pattern. If the relay dies after send() but before the commit, the event goes out twice on the next pass, and the consumer’s dedupe on the stable event id absorbs it, which is precisely the at-least-once contract the pattern promises and the reason the pitch says “without double-processing” rather than “without double-sending,” because sending twice is survivable and processing twice is not. If the commit itself times out, the keyed retry replays the receipt and the row is not double-marked, so the relay’s own crash story is as safe as the application’s write path. If send() keeps throwing, the attempts counter is your tripwire, and after some threshold you stop retrying and page someone, because an event that cannot publish after ten tries is a bug report, not a retry candidate. Run the relay as a systemd service, a supervisor program, or a cron that self-limits, because PHP’s shared-nothing request lifecycle means nothing runs in the background unless you schedule it, and that is fine; the relay does not need to live inside your web requests at all.

Where the engine’s CDC stream fits

Polling a table every two seconds works, and for most workloads it is all you will ever need, but it is worth knowing that MongrelDB ships the higher-octane version of the same idea, because the engine reconstructs committed transactions from its own WAL and exposes them through change_events_since(last_event_id), with stable event ids, aborted transactions never appearing, and a resumable cursor your relay can persist between runs. That turns the outbox table into an optional design choice rather than a requirement; if the only consumer of the change is your own relay and the latency budget is tight, tailing the commit stream is one less table and one less polling loop, while the explicit outbox table remains the right answer when you want to inspect pending events with plain SQL, re-drive a failed event by hand, or keep a durable audit of what was announced and when. The two compose well, too, because nothing stops you from writing the outbox row for the audit trail and driving the relay off the change stream for the latency.

The honest tradeoffs

The outbox buys you atomicity between state and announcement, and the bill arrives in three places that are worth naming plainly. The first is lag, because the event publishes when the relay gets to it, not when the order commits, so if your business rule is “the warehouse sees the order within a second,” the polling interval and the relay’s health become part of the contract, and you should monitor the age of the oldest unpublished row the way you would monitor a queue depth. The second is that at-least-once is a promise to the consumer as much as to you, because every downstream handler must treat the event id as a dedupe key, and the day someone writes a consumer that processes without checking, you have double-processing again, just relocated. The third is growth, since a busy outbox accumulates published rows forever unless you sweep them, and the sweep is a deliberate operational decision about how long you want the audit trail, not something the pattern settles for you. None of these are reasons to avoid the pattern; they are the reasons it is a pattern, a known shape with known costs, and the difference in 2026 is that the costs no longer include standing up a second distributed system to tail your first one, because the atomic batch commit and the storage-layer idempotency key were already in the engine you are running, and the pattern you used to rent from Kafka you now own in a few hundred lines.