Loa ding
FLASH SALE!!! Tools Kalkulator Gym, hanya 5k dengan kode ADS5K.
Learn more
Programming

Laravel Outbox Pattern: Fixing Dual-Write Bugs in SaaS Apps

A 4 min read 2 0
Laravel Outbox Pattern: Fixing Dual-Write Bugs in SaaS Apps
Sealed envelopes stacked in a wooden desk outbox tray under warm lamp

An order commits and the invoice row lands in Postgres, but the queue publish fails, times out, or never runs because the process died between the two calls. Billing now thinks the order exists, while the warehouse worker never hears about it. This gap is called the dual-write problem, and the transactional outbox pattern is the fix a dev.to author walks through for Laravel apps.

The author runs Loop Origin, which builds and hardens multi-tenant Laravel systems for product teams. He reaches for this pattern whenever a write must trigger a side effect that another service, queue, or webhook consumer depends on. The problem shows up in nearly every app whose flow is 'save first, then dispatch'.

How the transactional outbox pattern works

The idea is simple. Write the business row and an outbox row in the same database transaction. A separate worker then reads the unpublished outbox rows, publishes them to the broker, and marks them as published. Because delivery is at-least-once rather than exactly-once, consumers must be idempotent.

Naive code that saves the order inside a transaction and then publishes a message to RabbitMQ outside it looks safe in local testing. In production, the author lists four failure modes that are enough to keep him up at night:

  • The process dies after the commit but before the publish.
  • The publish succeeds, but the process dies before success is recorded, so a retry sends the message twice.
  • The broker accepts the message, then the consumer crashes mid-handler.
  • Clock skew or delayed jobs make 'already processed' checks return the wrong answer.

A database commit and a broker publish cannot be made atomic across two systems without a shared transaction protocol, which most teams do not run. The outbox accepts that reality and moves the side effect into durable storage first.

The outbox table and the publishing worker

The outbox table lives in the same database as the business data. For multi-tenant SaaS, the author adds a tenant_id column so ops can quarantine a single tenant without scanning the whole table. The other columns include aggregate_type, aggregate_id, event_type, a jsonb payload, available_at, published_at, attempts, and last_error. The dedupe_key column is the contract with producers, for example order.created:{order_id}, and a unique index on tenant_id plus dedupe_key blocks accidental double inserts when a request retries with the same logical event.

Advertisement

If the transaction rolls back, neither the order nor the outbox row exists. If it commits, the side effect stays safe even if Redis or RabbitMQ is down for the next ten minutes.

The worker uses FOR UPDATE SKIP LOCKED so multiple replicas can run without fighting over rows. In his example, the worker grabs a batch of 50 rows that are unpublished, past their available_at time, and under 25 attempts, then increments the attempts counter. If a publish fails, the error is stored and available_at moves forward with exponential backoff capped at 300 seconds. Two rules matter more than whichever broker SDK you use:

  • Publish headers must carry message_id (the outbox primary key) and dedupe_key.
  • Mark a row as published only after the broker acknowledges it. If you mark it first and the process crashes, the message is lost forever.

Schedule the worker densely: every few seconds in production, or as a long-running daemon with a short sleep. The author still keeps a Laravel scheduler entry as a watchdog in case the daemon dies.

Idempotent consumers: at-least-once is the contract

According to the author, end-to-end exactly-once delivery through a broker is just a marketing phrase unless you have a transactional consumer and a broker that participates. What you can realistically deliver is two things: at-least-once publishing from the outbox, and effectively-once business side effects through idempotent handlers.

On the consumer side, the handler opens a transaction, checks a ProcessedMessage table by dedupe_key with lockForUpdate, and stops if the message already exists. If not, it creates a WarehouseJob from the payload and records the dedupe_key, message_id, and processing time in the same transaction.

If you are building a SaaS product on Laravel, this pattern is relevant whenever an order has to reach a warehouse, a billing system, or a customer webhook. The tenant_id column in the outbox table also makes it easier to handle problems for one tenant without disrupting the others.

Advertisement
Category Programming
Share
A

Written by

Andreas Budi Ekayana

Andreas Budi Ekayana is the founder of AndreDEV Studio, a Bandung-based digital development studio established in 2011. Drawing on more than a decade of experience as a developer and designer, Andreas writes about techno...

See more articles

Comments 0

No comments yet. Be the first to share your thoughts.

Leave a comment

Comments are moderated automatically. Please be respectful. 0 / 1500