The Transactional Outbox in Laravel, Without the Exactly-Once Myth
An implementation-level Laravel outbox guide covering atomic writes, claiming, retries, idempotent consumers, ordering, schema evolution, monitoring, and cleanup.
If an order commit succeeds and publishing OrderPlaced fails, the rest of the system never hears about a real order. If publication succeeds and the worker crashes before recording success, consumers may receive the event twice. The transactional outbox solves the first gap and makes the second manageable. It does not create exactly-once delivery, and I do not think a design should pretend otherwise.
Write business state and intent atomically
Inside one database transaction, I write the aggregate change and an outbox row. The row contains a globally unique event ID, aggregate type and ID, event type, schema version, occurred-at timestamp, and a JSON payload containing a stable public contract. I never serialize an Eloquent model; that couples consumers to private columns and future refactors.
The event records something that happened, such as OrderPlaced, not an instruction such as SendOrderEmail. Consumers decide their own action. That keeps the producer focused on domain truth and allows more than one consumer without changing the order transaction.
DB::transaction(function () use ($command) {
$order = Order::place($command);
$order->save();
OutboxMessage::create([
'id' => (string) Str::uuid(),
'aggregate_type' => 'order',
'aggregate_id' => (string) $order->id,
'event_type' => 'order.placed',
'schema_version' => 1,
'payload' => OrderPlacedPayload::from($order),
'occurred_at' => now(),
]);
});Claim messages without blocking the table
Publishers claim a small batch using row locks and skip rows already locked by another worker. The claim has an owner and expiry so a crashed worker does not strand messages. Publishing happens outside a long database transaction; afterward the worker records success or releases the message with a retry time.
There is an unavoidable crash window after the broker accepts the message but before success is stored. The publisher will send it again. That is why the event ID is part of the public envelope and every consumer must be idempotent.
- Alert on age of the oldest unpublished message
- Use exponential backoff with a maximum
- Move poison messages to a visible failed state
- Do not let one bad event block later events forever
Make consumers idempotent at their own boundary
A consumer stores processed event IDs in the same transaction as its local side effect. If it updates a projection, both the projection and processed ID commit together. For an external provider, I pass the event or operation ID as the provider’s idempotency key when supported and reconcile unknown outcomes.
Checking a cache before processing is insufficient: two workers can race and caches expire. Idempotency belongs in durable state scoped to the consumer. Different consumers may retain IDs for different periods depending on retry and replay policy.
Promise only the ordering you need
Global event order is expensive and usually meaningless. I preserve order per aggregate when the business requires it, using aggregate version numbers and a partitioning key. Consumers reject or defer a gap rather than silently applying version 5 before version 4.
Many consumers do not need strict order if their operation is naturally idempotent or based on current source state. I write that choice down. Hidden ordering assumptions are the source of some of the most confusing event-driven bugs.
Evolve and operate the contract
Schema changes are additive by default. Consumers ignore unknown fields and handle unknown enum values. Contract examples live with consumers and run against the producer serializer in CI. If meaning must change, publish a new event version and support both during migration.
Operationally I watch publish throughput, retries, failure reason, oldest age, table size, cleanup lag, and consumer delay. Completed rows are archived or deleted after the replay and audit window. Cleanup is part of the design; an outbox table allowed to grow forever eventually becomes its own incident.
Use this in practice
- Write state and outbox in one transaction
- Publish a stable payload, not an Eloquent model
- Use leases and safe retries
- Require durable idempotency in every consumer
- Define ordering per aggregate only where needed
- Alert on oldest unpublished age and clean up completed rows
