Your SaaS order just committed. The invoice row is in Postgres. Somewhere else a queue publish failed, timed out, or never ran because the process died between the two calls. Now billing thinks the order exists and the warehouse worker never heard about it. That gap is the dual-write problem, and it shows up in every Laravel app that "saves then dispatches."
I run Loop Origin, where we build and harden multi-tenant Laravel systems for product teams. I wrote this with help from AI tools and checked every command and claim myself. This is the pattern I reach for when a write must produce a side effect that another service, queue, or webhook consumer depends on.
How does the transactional outbox pattern fix dual writes?
Direct answer: Write the business row and an outbox row in the same database transaction. A separate worker reads unpublished outbox rows, publishes to the broker, then marks them published. Consumers must be idempotent because delivery is at-least-once, not exactly-once. The database becomes the single source of truth for "this event must leave the system."
The dual-write failure that looks rare until it is not
Naive code looks fine in local testing:
DB::transaction(function () use ($order) {
$order->save();
});
Rabbit::publish('orders.created', $order->toArray()); // outside the txn
Four failure modes are enough to keep me awake:
- Process dies after commit, before publish.
- Publish succeeds, then the process dies before you record success, so a retry double-publishes.
- Broker accepts the message and the consumer crashes mid-handler.
- Clock skew or delayed jobs make "already processed" checks lie.
You cannot make a database commit and a broker publish atomic across two systems without a shared transaction protocol most teams do not run. The outbox accepts that truth and moves the side effect into durable storage first.
Outbox table schema that survives production
Keep the outbox in the same database as the business write. For multi-tenant SaaS I include tenant_id so ops can quarantine one tenant without scanning the whole table:
Schema::create('outbox_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('tenant_id')->index();
$table->string('aggregate_type', 64); // Order
$table->uuid('aggregate_id');
$table->string('event_type', 128); // order.created
$table->jsonb('payload');
$table->string('dedupe_key', 191); // stable per logical event
$table->timestampTz('available_at');
$table->timestampTz('published_at')->nullable();
$table->unsignedSmallInteger('attempts')->default(0);
$table->text('last_error')->nullable();
$table->timestampsTz();
$table->unique(['tenant_id', 'dedupe_key']);
$table->index(['published_at', 'available_at']); // worker scan
});
dedupe_key is the contract with producers: order.created:{order_id} or invoice.paid:{invoice_id}:{payment_intent}. The unique index stops accidental double inserts when a request retries with the same logical event.
Writing both rows together:
public function placeOrder(PlaceOrderData $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create([...$data->toArray(), 'status' => 'placed']);
OutboxMessage::create([
'id' => (string) Str::uuid(),
'tenant_id' => $order->tenant_id,
'aggregate_type' => 'Order',
'aggregate_id' => $order->id,
'event_type' => 'order.created',
'payload' => [
'order_id' => $order->id,
'total' => $order->total_cents,
'currency' => $order->currency,
],
'dedupe_key' => "order.created:{$order->id}",
'available_at' => now(),
]);
return $order;
});
}
If the transaction rolls back, neither the order nor the outbox row exists. If it commits, the side effect is durable even if Redis or RabbitMQ is down for the next ten minutes.
Publishing worker: claim, publish, mark
The worker must not race itself. I use FOR UPDATE SKIP LOCKED so multiple replicas can run safely:
public function handle(): void
{
$batch = DB::transaction(function () {
$rows = OutboxMessage::query()
->whereNull('published_at')
->where('available_at', '<=', now())
->where('attempts', '<', 25)
->orderBy('available_at')
->limit(50)
->lock('FOR UPDATE SKIP LOCKED')
->get();
foreach ($rows as $row) {
$row->increment('attempts');
}
return $rows;
});
foreach ($batch as $row) {
try {
$this->broker->publish($row->event_type, [
'message_id' => $row->id,
'dedupe_key' => $row->dedupe_key,
'tenant_id' => $row->tenant_id,
'payload' => $row->payload,
]);
$row->forceFill([
'published_at' => now(),
'last_error' => null,
])->save();
} catch (Throwable $e) {
$row->forceFill([
'last_error' => Str::limit($e->getMessage(), 2000),
'available_at' => now()->addSeconds(min(300, 2 ** min(8, $row->attempts))),
])->save();
}
}
}
Two rules matter more than the broker SDK:
- Publish headers must carry
message_id(outbox primary key) anddedupe_key. - Mark published only after the broker acknowledges. If you mark first, a crash loses the message forever.
Schedule it densely: every few seconds in production, or as a long-running daemon with a short sleep. I still keep a Laravel scheduler entry as a watchdog in case the daemon dies.
Idempotent consumers: at-least-once is the contract
Exactly-once end-to-end across a broker is a marketing phrase unless you have a transactional consumer and a broker that participates. What you can deliver is:
- At-least-once publish from the outbox.
- Effectively-once business side effects via idempotent handlers.
public function handle(OrderCreated $event): void
{
$applied = DB::transaction(function () use ($event) {
$exists = ProcessedMessage::query()
->where('dedupe_key', $event->dedupeKey)
->lockForUpdate()
->exists();
if ($exists) {
return false;
}
WarehouseJob::createFromOrder($event->payload);
ProcessedMessage::create([
'dedupe_key' => $event->dedupeKey,
'message_id' => $event->messageId,
'processed_at'=> now(),
]);
return true;
});
if (! $applied) {
return; // duplicate delivery
}
}
Put the idempotency record in the same transaction as the side effect. If the side effect is an external HTTP call, record intent before the call and a completion marker after, or make the external API itself idempotent with the same dedupe_key.
Comparison: dual-write vs outbox vs CDC
| Approach | Atomic with business write? | Ops burden | Latency | Typical failure |
|---|---|---|---|---|
| Naive dual-write (DB then broker) | No | Low until incidents | Lowest when healthy | Lost events after commit |
| Transactional outbox + worker | Yes (same DB txn) | Medium (table growth, worker HA) | Seconds unless polled aggressively | Poison rows, stuck retries |
| CDC (logical replication / Debezium) | Yes (log as source) | Higher (connector, schema drift) | Near-real-time | Connector lag, DDL breaks |
| Sync HTTP inside the request | No | Low | Adds request latency | Timeouts, partial success |
For most Laravel SaaS products under a few thousand events per minute, the outbox wins on clarity and cost. CDC earns its keep when many tables must emit streams or when you already run Kafka Connect for analytics. I wrote about related reliability themes in SaaS backup, disaster recovery, RPO and PITR and secure SaaS file uploads with signed URLs; outbox thinking pairs well with both because durable intent beats hope.
Failure modes I design for explicitly
Poison messages. A payload that always throws will burn attempts forever. Cap attempts, move exhausted rows to outbox_dead_letters, and alert with tenant and event type. Never auto-delete without a human path to replay.
Stuck unpublished rows. Alert when count(*) where published_at is null and available_at < now() - interval '5 minutes' crosses a threshold. This catches a dead worker faster than customer tickets.
Clock skew. Use the database clock for available_at comparisons inside SQL (now()), not three different app servers' clocks, when deciding what is due.
Giant payloads. Store a pointer (order_id) and let consumers load current state when possible. Snapshot only the fields the consumer must not re-read, such as price at purchase time.
Multi-tenant noisy neighbor. Index and partition by tenant_id or at least filter worker batches so one abusive tenant cannot monopolize the publisher.
Partial consumer success. If a handler sends three emails and fails on the second, redesign for one logical effect or record sub-step markers. Prefer one downstream command per outbox event.
Checkout flows that split work across sellers make this especially sharp; see multi-vendor checkout splitting one cart by seller for the business shape that often needs several outbox events in one transaction.
Ops and cost checklist
- [ ] Business write and outbox insert share one DB transaction
- [ ] Unique
dedupe_keyper logical event - [ ] Worker uses
SKIP LOCKEDand marks published only after broker ack - [ ] Message headers include
message_idanddedupe_key - [ ] Consumers record processed keys in the same txn as side effects
- [ ] Attempt cap + dead-letter table + alert on lag
- [ ] Retention job archives published rows older than N days
- [ ] Runbook for replay from dead letters
- [ ] Load test at 2× peak publish rate with broker down for 5 minutes (catch-up)
Frequently asked questions
Is the outbox the same as Laravel's jobs table?
No. Queue jobs are usually enqueued after the request commits, which reintroduces dual-write unless you insert the job row inside the same transaction and use a DB queue driver. An explicit outbox keeps event semantics and transport separate and works when the broker is Redis, RabbitMQ or SNS.
Do I need exactly-once delivery?
You need exactly-once effects. At-least-once delivery plus idempotent consumers is the practical path on commodity brokers.
Should every domain event go through the outbox?
No. Use it when a missed side effect creates money, inventory, compliance or customer-visible inconsistency. Logging and non-critical analytics can stay best-effort.
How large will the outbox table get?
Plan retention. Keep unpublished forever until resolved; archive published rows on a schedule. Partitioning by month helps once you pass tens of millions of rows.
Can I use the outbox with MongoDB or multiple databases?
The outbox must live in the same transactional store as the business write. Cross-database dual writes bring the original problem back.
What about using Change Data Capture instead?
CDC is excellent when you need streams from many tables or cannot change application code. For a single Laravel app you control, an application-level outbox is usually simpler to reason about and debug.
When we take on custom web application development for teams with multi-service boundaries, this pattern is one of the first reliability reviews I run. It is not glamorous. It is the difference between "we think the event sent" and "the database proves it will."
Follow & Connect
- LinkedIn: Loop Origin
- X: Muhammad Ali Mughal (@AliMughal887)
- GitHub: Loop Origin (@LoopOrigin)

Top comments (0)