webrek / laravel-outbox
Transactional outbox for Laravel: stage messages inside your database transaction and relay them reliably with retries and backoff.
Requires
- php: ^8.2
- illuminate/console: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
Requires (Dev)
- infection/infection: ^0.29
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
README
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.connectionaccordingly (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.