Writing a row and publishing an event are two systems, and there is no way to make them atomic — so either the row exists and no event was sent, or the event was sent and the transaction rolled back.
DB::transaction(function () use ($order) {
$order->save();
// same transaction, same database. either both or neither.
DB::table('outbox')->insert([
'topic' => 'orders.placed',
'payload' => json_encode($order->toEventPayload()),
'created_at' => now(),
]);
});
// a separate worker reads the outbox and publishes, marking as sent
The relay gives at-least-once delivery — it can publish and crash before marking the row sent — which is why consumers must be idempotent regardless. Polling the outbox every second is unglamorous and works; reading the binlog is the sophisticated version and is a lot more machinery. The failure this removes is the silent one, where a system is internally consistent and everything downstream is missing an event nobody noticed.