SupportWriter space ↗
← Back to the journal
Laravel

Transactional Outbox in Laravel for Reliable Job Delivery

Close the gap between database commit and dispatch queue using outbox tables and idempotent consumers.

DAdministrator · 13 Oct 2026 · 4 min read

You are reading a translated version.

The application saves the order and then sends the job email. If the dispatch occurs before committing, the worker can read orders that are not yet available. Laravel provides afterCommit() for the sequence. But there is another loophole: the application process can stop after the commit is successful but before the job is sent to the broker.

Transactional outbox stores the intention to send events in the same database as the order. The relay read the intention and sent it to the queue. The following Laravel 13 example deliberately limits one active relay to make the delivery mechanism easy to follow. For some parallel relays, atomic claiming or a row lock strategy appropriate to the database engine is required.

Set up outbox table

Create migration with Artisan. Table outbox_messages have id UUID as primary key, type string payload JSON published_at nullable, and timestamps. Add index published_at,created_at for pending message searches. The order table example has id, user_id, and status.

Save orders and messages using the same connection:

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

$orderId = DB::transaction(function () use ($userId): int {
    $orderId = DB::table('orders')->insertGetId([
        'user_id' => $userId,
        'status' => 'pending',
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    DB::table('outbox_messages')->insert([
        'id' => (string) Str::uuid(),
        'type' => 'order.created',
        'payload' => json_encode(['order_id' => $orderId], JSON_THROW_ON_ERROR),
        'published_at' => null,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    return $orderId;
});

If the message insert fails, the order follows the rollback. If the process dies after committing, the message still exists and can be read by the next relay. The UUID becomes the identity of the event that is passed on to the consumer; do not generate a new UUID every time the relay retries.

Relay sending before marking published

Create command php artisan make:command PublishOutbox --no-interaction, with signature outbox:publish. Here's the core of the method handle()Import DB and application jobs HandleOrderCreated. Job must implement ShouldQueue, and the queue connection must be completely asynchronous.

$messages = DB::table('outbox_messages')
    ->whereNull('published_at')
    ->orderBy('created_at')
    ->orderBy('id')
    ->limit(100)
    ->get();

foreach ($messages as $message) {
    $payload = json_decode($message->payload, true, 512, JSON_THROW_ON_ERROR);

    $job = (new HandleOrderCreated(
        eventId: $message->id,
        orderId: (int) $payload['order_id'],
    ))->onConnection('redis');

    app(\Illuminate\Contracts\Bus\Dispatcher::class)->dispatch($job);

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

return self::SUCCESS;

The Dispatcher call here enqueues before updating the marker. Do not mark published then dispatch: failure of dispatch will make the message skip forever. Connection name redis it is necessary to configure Redis and the appropriate extension or driver; use the asynchronous connection already available in the application, not copy the configuration without checking.

Duplicates are still possible

If the relay succeeds enqueue then turns off before marking published, the message is sent again. Outbox offers shipping at least once, not a guarantee of exactness. Consumers must accept eventId and duplicate based on the unique index in the table processed_events.

For business effects that are entirely in the database, insert processed_events and business changes are made in the same transaction. For external email or HTTP, the database transaction is not enough: the process can die after the external service is successful before the local marker is saved. Use the idempotency key of the destination service when available, or state that external effects can recur.

Schedules, failures, and observability

registered Schedule::command('outbox:publish')->everyMinute()->withoutOverlapping() on the application schedule. If the scheduler is running on multiple servers, use onOneServer() with a central cache that supports lock. All manual execution of relays must also respect the restrictions of this one relay.

Do not delete pending messages when there is a damaged payload. Record message IDs and errors, give retry limits and quarantine statuses so that one bad message does not hold the next queue. Monitor the age of the oldest messages, the number of pending, and consumer failures. published_at only means that it was successfully sent to the queue, not successfully handled.

Test rollback orders, enqueue failures, re-run relays, and duplicate event consumption. Simulate dying after enqueue so that the contract of idempotency is actually proven.

API Reference: Jobs and database transactions and Database transactions.

Comments 0

Share your questions, experiences, or feedback about this article.

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

← Explore more notes