DEV Community

Cover image for Transactional Outbox Pattern in Laravel SaaS
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Transactional Outbox Pattern in Laravel SaaS

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
Enter fullscreen mode Exit fullscreen mode

Four failure modes are enough to keep me awake:

  1. Process dies after commit, before publish.
  2. Publish succeeds, then the process dies before you record success, so a retry double-publishes.
  3. Broker accepts the message and the consumer crashes mid-handler.
  4. 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
});
Enter fullscreen mode Exit fullscreen mode

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;
    });
}
Enter fullscreen mode Exit fullscreen mode

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.

Sealed envelopes stacked in a wooden desk outbox tray under warm lamp light, metaphor for durable messages waiting to be published
AI-generated image.

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two rules matter more than the broker SDK:

  • Publish headers must carry message_id (outbox primary key) and dedupe_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
    }
}
Enter fullscreen mode Exit fullscreen mode

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_key per logical event
  • [ ] Worker uses SKIP LOCKED and marks published only after broker ack
  • [ ] Message headers include message_id and dedupe_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

Top comments (0)