Hard Laravel

Transactional Outbox in Laravel: Never Lose an Event Between Your Database and the Broker

Writing to the database and a broker in one request loses or invents events on crashes. Fix the dual-write problem with an outbox table, relay and idempotent consumers.

08 Nov, 2026 9 min Updated 23 Oct, 2026 7 Code blocks
Diagram
sequenceDiagram participant S as Order service participant DB as Database participant R as Relay worker participant B as Broker participant C as Consumer S->>DB: insert order and outbox row in one transaction R->>DB: select unpublished rows skip locked R->>B: publish message with message_id R->>DB: set published_at B->>C: deliver at least once C->>C: skip if message_id already processed

A customer pays for an order. The order row is committed, the payment is captured, and then the warehouse service never ships it. The logs show the orders.placed message was never published: the PHP worker was killed by a deploy two milliseconds after the commit. A week earlier the opposite happened: the message was published first, the database transaction rolled back on a deadlock, and the warehouse started picking an order that does not exist.

Both incidents are the same bug: your code writes to the database and a broker, and no transaction spans both. Whichever one you write first, a crash between the two writes leaves them disagreeing. The transactional outbox pattern removes the second write from the request entirely.

The Dual-Write Problem

Here is the code that caused the first incident. It looks reasonable, and it works in every test you will ever write:

use App\Models\Order;
use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($cart) {
    $order = Order::createFromCart($cart);
    $order->items()->createMany($cart->lines());

    return $order;
});

// The process can die right here: OOM, deploy, network blip.
// The order exists, but nobody downstream ever hears about it.
$broker->publish('orders.placed', ['order_id' => $order->id]);

Publish inside the transaction instead and you get the second incident: the commit fails after the broker took the message. Laravel's DB::afterCommit() and ShouldDispatchAfterCommit events improve the ordering but not the guarantee: the callback still runs in the same PHP process after the commit, so a crash, timeout or broker outage at that moment still loses the message.

There is no clever ordering that fixes this. What fixes it is having only one write that matters, inside one transaction.

The Outbox Table

Instead of talking to the broker, the request writes the message into a table in the same database, in the same transaction as the business data. Either both the order and the message exist, or neither does. A separate relay process reads that table and publishes to the broker, retrying until it succeeds.

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('outbox_messages', function (Blueprint $table) {
            $table->id();
            $table->uuid('message_id')->unique();
            $table->string('topic');
            $table->string('aggregate_type');
            $table->string('aggregate_id');
            $table->json('payload');
            $table->unsignedSmallInteger('attempts')->default(0);
            $table->timestamp('available_at')->useCurrent();
            $table->timestamp('published_at')->nullable();
            $table->timestamp('created_at')->useCurrent();

            $table->index(['published_at', 'available_at', 'id']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('outbox_messages');
    }
};

Notes:

  • message_id is a UUID that travels with the message. Consumers use it to detect duplicates.
  • aggregate_type and aggregate_id say what the message is about, for per-entity ordering and debugging.
  • available_at and attempts give you backoff without a separate retry queue.
  • The (published_at, available_at, id) index keeps "oldest pending" a range scan.

Recording Messages in the Same Transaction

A small service writes the row. It refuses to run outside a transaction, which turns the most common mistake into an immediate exception in development:

namespace App\Outbox;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use LogicException;

final class Outbox
{
    public function record(string $topic, string $aggregateType, string|int $aggregateId, array $payload): string
    {
        if (DB::transactionLevel() === 0) {
            throw new LogicException('Outbox messages must be recorded inside a database transaction.');
        }

        $messageId = (string) Str::uuid();

        DB::table('outbox_messages')->insert([
            'message_id' => $messageId,
            'topic' => $topic,
            'aggregate_type' => $aggregateType,
            'aggregate_id' => (string) $aggregateId,
            'payload' => json_encode($payload, JSON_THROW_ON_ERROR),
        ]);

        return $messageId;
    }
}

The use case then records the message right next to the business writes:

namespace App\Orders;

use App\Models\Cart;
use App\Models\Order;
use App\Outbox\Outbox;
use Illuminate\Support\Facades\DB;

final class PlaceOrder
{
    public function __construct(private Outbox $outbox) {}

    public function __invoke(Cart $cart): Order
    {
        return DB::transaction(function () use ($cart) {
            $order = Order::createFromCart($cart);
            $order->items()->createMany($cart->lines());

            $this->outbox->record('orders.placed', 'order', $order->id, [
                'order_id' => $order->id,
                'customer_id' => $order->customer_id,
                'total' => $order->total,
                'placed_at' => $order->created_at->toIso8601String(),
            ]);

            return $order;
        });
    }
}

If anything in the closure throws, the order, its items and the outbox row disappear together. If it commits, the message is guaranteed to be published eventually, even if the PHP process dies one microsecond later.

The Relay Worker

The relay is a loop: select a batch of pending messages, publish each one, mark it published. Here is the full flow, from the request to the consumer:

sequenceDiagram participant S as Order service participant DB as Database participant R as Relay worker participant B as Broker participant C as Consumer S->>DB: insert order and outbox row in one transaction R->>DB: select unpublished rows skip locked R->>B: publish message with message_id R->>DB: set published_at B->>C: deliver at least once C->>C: skip if message_id already processed

In Laravel, the relay is an Artisan command run by Supervisor (or a scheduled --once invocation for low volume):

namespace App\Console\Commands;

use App\Outbox\MessagePublisher;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;

final class RelayOutbox extends Command
{
    protected $signature = 'outbox:relay {--batch=100} {--once}';

    protected $description = 'Publish pending outbox messages to the broker';

    public function handle(MessagePublisher $publisher): int
    {
        do {
            $sent = $this->relayBatch($publisher, (int) $this->option('batch'));

            if ($sent === 0 && ! $this->option('once')) {
                usleep(500_000);
            }
        } while (! $this->option('once'));

        return self::SUCCESS;
    }

    private function relayBatch(MessagePublisher $publisher, int $limit): int
    {
        return DB::transaction(function () use ($publisher, $limit) {
            $messages = DB::table('outbox_messages')
                ->whereNull('published_at')
                ->where('available_at', '<=', now())
                ->orderBy('id')
                ->limit($limit)
                ->lock('for update skip locked')
                ->get();

            $sent = 0;

            foreach ($messages as $message) {
                try {
                    $publisher->publish($message->topic, $message->message_id, json_decode($message->payload, true));
                } catch (Throwable $e) {
                    report($e);
                    DB::table('outbox_messages')->where('id', $message->id)->update([
                        'attempts' => $message->attempts + 1,
                        'available_at' => now()->addSeconds(min(300, 2 ** $message->attempts)),
                    ]);

                    break;
                }

                DB::table('outbox_messages')->where('id', $message->id)->update(['published_at' => now()]);
                $sent++;
            }

            return $sent;
        });
    }
}

Design choices in that command:

  • FOR UPDATE SKIP LOCKED. You will run more than one relay, during deploys if nothing else. Row locks with SKIP LOCKED (MySQL 8.0+ and PostgreSQL) let several workers share the table without publishing the same row twice and without blocking each other.
  • Order by id and stop on the first failure. Breaking out of the loop keeps messages in commit order, which matters when consumers expect orders.placed before orders.cancelled. If you need parallelism, partition by aggregate_id instead of dropping ordering globally.
  • Backoff via available_at. A broker outage does not become a hot loop.
  • Publish, then mark. If the worker crashes after publishing but before the update commits, the message is published again on the next run. That is the price of the pattern: at-least-once delivery. Exactly-once delivery across a network does not exist, so the consumer has to cope.

Keep batches small: locks are held while you talk to the broker, so 100 messages at 50 ms each hold locks for 5 seconds.

Idempotent Consumers

Because the relay can deliver a message twice, and most brokers redeliver on consumer crashes anyway, every consumer must be safe to run twice with the same message. The robust way is a processed_messages table with a unique key on message_id, written in the same transaction as the consumer's own side effects:

namespace App\Warehouse;

use Illuminate\Support\Facades\DB;

final class HandleOrderPlaced
{
    public function __construct(private ShipmentPlanner $planner) {}

    public function __invoke(string $messageId, array $payload): void
    {
        DB::transaction(function () use ($messageId, $payload) {
            $inserted = DB::table('processed_messages')->insertOrIgnore([
                'message_id' => $messageId,
                'consumer' => 'warehouse.order-placed',
                'processed_at' => now(),
            ]);

            if ($inserted === 0) {
                return;
            }

            $this->planner->planFor((int) $payload['order_id']);
        });
    }
}

insertOrIgnore returns the number of inserted rows. Zero means another delivery of the same message already got there first, so you return without doing the work. Because the dedupe row and the shipment plan commit together, a crash halfway leaves neither, and the redelivery runs the full handler again.

Naturally idempotent handlers ("set status to shipped", an upsert keyed by order_id) can skip the table. "Add loyalty points" or "send an email" cannot.

Testing the Outbox

The properties to prove are the ones that failed in production: the message is written atomically with the order, nothing is written on rollback, and a broker failure is retried rather than lost. Bind a fake publisher in the container and drive the relay with --once:

use App\Models\Cart;
use App\Orders\PlaceOrder;
use App\Outbox\MessagePublisher;
use Illuminate\Support\Facades\DB;

final class FakePublisher implements MessagePublisher
{
    public array $sent = [];

    public bool $failNext = false;

    public function publish(string $topic, string $messageId, array $payload): void
    {
        if ($this->failNext) {
            $this->failNext = false;

            throw new RuntimeException('broker down');
        }

        $this->sent[] = [$topic, $messageId, $payload];
    }
}

beforeEach(function () {
    $this->publisher = new FakePublisher();
    $this->app->instance(MessagePublisher::class, $this->publisher);
});

it('writes the order and its outbox message in one transaction', function () {
    $order = app(PlaceOrder::class)(Cart::factory()->withLines(2)->create());

    $this->assertDatabaseHas('outbox_messages', [
        'topic' => 'orders.placed',
        'aggregate_id' => (string) $order->id,
        'published_at' => null,
    ]);
});

it('publishes pending messages and retries after a broker failure', function () {
    app(PlaceOrder::class)(Cart::factory()->withLines(1)->create());
    $this->publisher->failNext = true;

    $this->artisan('outbox:relay', ['--once' => true])->assertSuccessful();
    expect($this->publisher->sent)->toBeEmpty();

    $this->travel(5)->seconds();
    $this->artisan('outbox:relay', ['--once' => true])->assertSuccessful();

    expect($this->publisher->sent)->toHaveCount(1)
        ->and(DB::table('outbox_messages')->whereNull('published_at')->count())->toBe(0);
});

$this->travel() moves Laravel's clock past the backoff window, so the retry test does not sleep. Also test the rollback case (throw inside an outer transaction, assert outbox_messages is empty) and the consumer: call it twice with the same message_id and assert the side effect happened once.

Common Pitfalls

  • Recording outside the transaction. Calling record() after DB::transaction() returns brings the dual-write problem straight back. The transactionLevel() guard catches this early.

  • Huge payloads. The outbox is not a document store. Put IDs and the fields consumers need, not the entire model graph with relations.

  • Unbounded table growth. Published rows pile up fast. Schedule a daily job that deletes rows published more than seven days ago, in chunks of a few thousand.

  • Assuming exactly-once. Every consumer you do not control will receive duplicates eventually. Send message_id in a header and document that it is the dedupe key.

  • Never restarting the relay. Exit after N batches, like queue:work --max-jobs, and let Supervisor restart it with fresh code and config.

  • Monitoring nothing. Alert on the age of the oldest unpublished message. If it grows, the relay is stuck, and orders are silently waiting.

When to Use the Outbox and When Not To

Use the transactional outbox when:

  • a database change must be followed by a message to another service or broker, and losing or inventing that message has a business cost: orders, payments, inventory, account state;
  • you integrate services over Kafka, RabbitMQ, SQS or webhooks and cannot share a database transaction with them;

You probably do not need it when:

  • the side effect is best effort: analytics pings, cache warming, a "welcome" email where a rare miss is acceptable. afterCommit dispatch is simpler;
  • everything happens inside one Laravel app with the database queue driver. A job dispatched inside the transaction on the database queue is already written atomically with your data, which is effectively an outbox;
  • you already run change data capture (Debezium reading the binlog). CDC can replace the polling relay, though the outbox table is still a useful explicit contract.

One table, one worker, one dedupe check per consumer, and "paid but never shipped" stops being a 2 AM incident.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement two pieces of an outbox. relay_batch (JS: relayBatch) receives an array of OutboxMessage objects (id, messageId, payload, published, attempts), a publish callback and a limit. Take only messages with published false, ordered by id ascending, at most limit of them. Call publish(message) for each in order: on success set published to true; if publish throws, increment that message's attempts and stop processing the batch immediately (later messages stay unpublished to keep ordering). Return the number of messages published in this call. IdempotentConsumer wraps a handler. consume(message) returns false without calling the handler if that messageId was already processed successfully. Otherwise it calls the handler; if the handler throws, the exception propagates and the messageId must NOT be remembered, so a redelivery runs the handler again. After a successful handler call, remember the messageId and return true.

Starter code

php
final class OutboxMessage
{
    public function __construct(
        public int $id,
        public string $messageId,
        public array $payload,
        public bool $published = false,
        public int $attempts = 0,
    ) {}
}

function relay_batch(array $messages, callable $publish, int $limit): int
{
    // TODO: take unpublished messages ordered by id, at most $limit of them
    // TODO: call $publish($message) for each one; on success mark it published
    // TODO: on an exception, increment attempts on that message and stop the batch
    // TODO: return how many messages were published
}

final class IdempotentConsumer
{
    private array $processed = [];

    public function __construct(private Closure $handler) {}

    public function consume(OutboxMessage $message): bool
    {
        // TODO: return false without calling the handler if messageId was already processed
        // TODO: call the handler; only remember messageId if the handler did not throw
        // TODO: return true after a successful first processing
    }
}
javascript
class OutboxMessage {
  constructor(id, messageId, payload) {
    this.id = id;
    this.messageId = messageId;
    this.payload = payload;
    this.published = false;
    this.attempts = 0;
  }
}

function relayBatch(messages, publish, limit) {
  // TODO: take unpublished messages ordered by id, at most limit of them
  // TODO: call publish(message) for each one; on success mark it published
  // TODO: on an exception, increment attempts on that message and stop the batch
  // TODO: return how many messages were published
  throw new Error('TODO');
}

class IdempotentConsumer {
  constructor(handler) {
    this.handler = handler;
    this.processed = new Set();
  }

  consume(message) {
    // TODO: return false without calling the handler if messageId was already processed
    // TODO: call the handler; only remember messageId if the handler did not throw
    // TODO: return true after a successful first processing
    throw new Error('TODO');
  }
}

Your solution

Hints

All hints revealed
Hint 1

Filter, sort by id, then slice to the limit before the loop, so a failure does not change which messages were selected.

Hint 2

Use break inside the catch block to keep later messages for the next run.

Hint 3

Record the messageId only after the handler returns; a throw skips that line automatically.

Solution · php
final class OutboxMessage
{
    public function __construct(
        public int $id,
        public string $messageId,
        public array $payload,
        public bool $published = false,
        public int $attempts = 0,
    ) {}
}

function relay_batch(array $messages, callable $publish, int $limit): int
{
    $pending = array_values(array_filter($messages, fn (OutboxMessage $m): bool => ! $m->published));
    usort($pending, fn (OutboxMessage $a, OutboxMessage $b): int => $a->id <=> $b->id);

    $sent = 0;

    foreach (array_slice($pending, 0, $limit) as $message) {
        try {
            $publish($message);
        } catch (Throwable) {
            $message->attempts++;

            break;
        }

        $message->published = true;
        $sent++;
    }

    return $sent;
}

final class IdempotentConsumer
{
    private array $processed = [];

    public function __construct(private Closure $handler) {}

    public function consume(OutboxMessage $message): bool
    {
        if (isset($this->processed[$message->messageId])) {
            return false;
        }

        ($this->handler)($message);
        $this->processed[$message->messageId] = true;

        return true;
    }
}
Solution · javascript
class OutboxMessage {
  constructor(id, messageId, payload) {
    this.id = id;
    this.messageId = messageId;
    this.payload = payload;
    this.published = false;
    this.attempts = 0;
  }
}

function relayBatch(messages, publish, limit) {
  const pending = messages.filter((m) => !m.published).sort((a, b) => a.id - b.id).slice(0, limit);
  let sent = 0;
  for (const message of pending) {
    try {
      publish(message);
    } catch (e) {
      message.attempts += 1;
      break;
    }
    message.published = true;
    sent += 1;
  }
  return sent;
}

class IdempotentConsumer {
  constructor(handler) {
    this.handler = handler;
    this.processed = new Set();
  }

  consume(message) {
    if (this.processed.has(message.messageId)) {
      return false;
    }
    this.handler(message);
    this.processed.add(message.messageId);
    return true;
  }
}