webrek/laravel-outbox

Transactional outbox for Laravel: stage messages inside your database transaction and relay them reliably with retries and backoff.

Maintainers

Package info

github.com/webrek/laravel-outbox

pkg:composer/webrek/laravel-outbox

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.2.0 2026-06-17 01:41 UTC

This package is auto-updated.

Last update: 2026-07-04 23:16:22 UTC


README

Latest Version on Packagist Total Downloads Tests PHP Version License

A transactional outbox for Laravel. Write a message inside the same database transaction as your business write, and a relay delivers it afterwards with retries and backoff. The write and the message commit together —either both land or neither does— so you never publish an event for a change that was rolled back, nor lose an event for a change that was committed.

This is the producer half of exactly-once. Pair it with webrek/laravel-idempotency on the consumer to get end-to-end exactly-once effects on top of at-least-once infrastructure.

Why

Dispatching a queued job, firing a webhook, or publishing to a broker after saving a model is a dual write: if the process dies between the commit and the dispatch, the side effect is lost. Doing it before the commit is worse: the effect fires even if the transaction is rolled back. The outbox pattern closes that gap by writing the intent to the same database, within the same transaction, and delivering it from there.

use Illuminate\Support\Facades\DB;
use Webrek\Outbox\Facades\Outbox;

DB::transaction(function () use ($request) {
    $order = Order::create($request->validated());

    // Commits atomically with the order. No order, no message — and vice versa.
    Outbox::publish('order.placed', ['order_id' => $order->id]);
});

Installation

composer require webrek/laravel-outbox

Publish and run the migration:

php artisan vendor:publish --tag=outbox-migrations
php artisan migrate

Optionally publish the configuration:

php artisan vendor:publish --tag=outbox-config

The outbox table must live on the same connection as the data you write the messages alongside —atomicity only spans a single-connection transaction. Set outbox.connection accordingly (it defaults to your default connection).

Delivering messages through the relay

Run the relay as a long-running worker (like queue:work):

php artisan outbox:work

It claims due messages with a row lock —it is safe to run several workers in parallel—, delivers each one to a publisher, and marks it as published. A failed delivery is retried with exponential backoff up to max_attempts, after which the message is discarded. A message left in processing by a worker that crashed is reclaimed once claim_timeout passes.

Process a single batch and exit (useful for scheduled tasks or tests):

php artisan outbox:work --once

Prune already-delivered messages on a schedule:

use Illuminate\Support\Facades\Schedule;

Schedule::command('outbox:prune')->daily();   // keeps the last `prune.retention_hours`

Delivering messages to the outside world

How a message reaches the outside world depends on a publisher. Out of the box the package ships EventPublisher, which turns each message into an OutboxMessageReady event you can listen for:

use Webrek\Outbox\Events\OutboxMessageReady;

Event::listen(OutboxMessageReady::class, function (OutboxMessageReady $event) {
    $message = $event->message;

    Http::post('https://example.test/hooks', $message->payload)->throw();
});

Delivery is synchronous: if the listener throws an exception, the message is rescheduled; if it returns, the message is marked as published.

Prefer a dedicated class? Implement the contract and point the configuration at it:

use Webrek\Outbox\Contracts\Publisher;
use Webrek\Outbox\Models\OutboxMessage;

class BrokerPublisher implements Publisher
{
    public function publish(OutboxMessage $message): void
    {
        // push to Kafka / RabbitMQ / SNS / an HTTP endpoint…
        // throw an exception to retry, return to acknowledge.
    }
}
// config/outbox.php
'publisher' => App\Outbox\BrokerPublisher::class,

Observability

The relay fires lifecycle events you can hook into for metrics and alerting:

Event When
OutboxMessagePublished A message was delivered successfully.
OutboxMessageFailed An attempt failed; the message will be retried.
OutboxMessageDiscarded The retry budget was exhausted; the message is abandoned.

Each one carries the OutboxMessage; the failure events also carry the Throwable.

Recovering discarded messages

A message that exhausts its retry budget is marked as failed and left in the table for inspection —it is never discarded silently. Once you have fixed the downstream system, move the messages back to pending so the relay tries them again with a fresh budget:

php artisan outbox:retry --all          # all discarded messages
php artisan outbox:retry <id> <id># specific messages

To spread out the retries of a large backlog so they don't all fire at once, raise retry.jitter (0–1) before reprocessing.

Inspecting the outbox

See at a glance how many messages are in each state —and how far behind the oldest pending one is:

php artisan outbox:status

Faking it in tests

Outbox::fake() swaps the outbox for an in-memory recorder, so your application tests can assert what would be published without writing to the database or running the relay:

use Webrek\Outbox\Facades\Outbox;

Outbox::fake();

$this->post('/orders', [...]);

Outbox::assertPublished('order.placed', fn ($message) => $message->payload['id'] === $order->id);
Outbox::assertPublishedTimes('order.placed', 1);
Outbox::assertNothingPublished();   // or verify that nothing leaked

Configuration

return [
    'connection' => env('OUTBOX_CONNECTION'),   // same connection as your business data
    'table' => 'outbox_messages',
    'publisher' => Webrek\Outbox\Publishers\EventPublisher::class,
    'max_attempts' => 10,                        // attempts before discarding
    'batch_size' => 100,                         // messages claimed per relay pass
    'claim_timeout' => 300,                       // seconds before reclaiming a stuck message
    'retry' => [
        'base_seconds' => 10,                     // delay = base * multiplier^(attempt - 1)
        'max_seconds' => 3600,
        'multiplier' => 2,
        'jitter' => 0.0,                          // 0–1: spreads retries to avoid a thundering herd
    ],
    'prune' => [
        'retention_hours' => 168,
    ],
];

Requirements

Component Version
PHP 8.2+
Laravel 12.x / 13.x
Database Any with transaction support (PostgreSQL, MySQL/MariaDB, SQLite, SQL Server)

Testing

composer install
composer test

Contributing

See CONTRIBUTING.md.

Security

Review the security policy before reporting a vulnerability.

License

Released under the MIT license.