dnakitare / laravel-outbox
Transactional outbox pattern for Laravel — events and jobs persisted atomically with your business writes, then replayed reliably with backoff and dead-letter.
Requires
- php: ^8.2
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.49
- larastan/larastan: ^2.0|^3.0
- laravel/pint: ^1.13
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0|^9.0|^10.0
- orchestra/workbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- phpstan/phpstan: ^1.0|^2.0
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-04-19 02:10:01 UTC
README
⚠️ Beta software. This package is at
0.1.0-beta1and has not yet been battle-tested in production. Adopt with eyes open, file issues generously. We commit to strict SemVer starting at1.0.0.
A production-grade implementation of the Transactional Outbox Pattern for Laravel.
Events and queued jobs dispatched inside Outbox::transaction() are
persisted to an outbox_messages table atomically with your business
writes. A worker then replays them against the real event dispatcher
and job queue. If the downstream fails, messages retry with
exponential backoff, and ultimately land in a dead-letter table where
they can be inspected and manually reset.
Requirements
- PHP 8.2+ (PHP 8.1 is EOL)
- Laravel 10, 11, or 12
- A database that supports row-level locks: MySQL 8+, MariaDB 10.6+, PostgreSQL 9.5+. SQLite works for testing but serialises workers.
Installation
composer require dnakitare/laravel-outbox php artisan vendor:publish --tag=outbox-config php artisan vendor:publish --tag=outbox-migrations php artisan migrate
Usage
use Dnakitare\Outbox\Facades\Outbox; Outbox::transaction('Order', $order->id, function () use ($order) { $order->save(); event(new OrderCreated($order)); SendReceipt::dispatch($order); });
Inside the closure, event() and dispatch() are intercepted: nothing
fires on the real event bus or goes to the real queue. They are
persisted with your $order->save() in one SQL transaction. After
commit, a worker (outbox:process) picks them up and fires them for
real.
Production checklist
This is a database-integrity-critical piece of your system. Go through this list before relying on it in production.
1. Allowlist every event and job class you dispatch
Outbox refuses to deserialize classes that aren't explicitly allowed.
Add yours to config/outbox.php:
'serialization' => [ 'allowed_classes' => [ App\Events\OrderCreated::class, App\Events\OrderShipped::class, App\Jobs\SendReceipt::class, App\Jobs\NotifyWarehouse::class, ], ],
This is defence-in-depth on top of HMAC integrity. An attacker who
somehow gets write access to outbox_messages still cannot execute
arbitrary classes. Forgetting to add a class sends its messages to
dead-letter — not to execution.
2. Run the worker from the scheduler
Pick one pattern. Do not mix them — you'll get duplicate work.
Option A — long-running supervised worker (recommended at scale):
Use Supervisor / systemd / Horizon to keep this running:
php artisan outbox:process --batch=100 --sleep=1
Option B — cron-driven bursts (simpler, lower throughput):
In app/Console/Kernel.php (Laravel 10) or routes/console.php
(Laravel 11+):
$schedule->command('outbox:process --once --batch=200') ->everyMinute() ->withoutOverlapping(5) ->runInBackground();
Option C — inline after each transaction (dev/low-volume):
OUTBOX_PROCESS_IMMEDIATELY=true
Each transaction commit dispatches a ProcessOutboxMessages job to
your normal queue. Requires a running queue:work or Horizon worker.
3. Prune old rows on a schedule
The tables grow forever otherwise.
$schedule->command('outbox:prune --force')->dailyAt('03:00');
Tune retention via config/outbox.php — pruning.retention_days
and dead_letter.retention_days.
4. Monitor health and alert
Wire Outbox::health() into whatever your ops team watches. It
returns status: healthy|warning|critical. critical means at least
one message has been stuck in processing longer than
outbox.processing.lock_timeout seconds — a worker almost certainly
died mid-batch and those messages will NOT retry automatically until
a human resets them.
Route::get('/_health/outbox', function () { $health = Outbox::health(); $status = match ($health['status']) { 'healthy' => 200, 'warning' => 200, // Still serving; let your graphs fire. 'critical' => 503, }; return response()->json($health, $status); })->middleware('internal-only');
5. Subscribe to observability events
Three events fire during the outbox lifecycle. Wire them to whatever metric backend you use:
// app/Providers/EventServiceProvider.php protected $listen = [ \Dnakitare\Outbox\Events\MessagesStored::class => [ \App\Listeners\Outbox\RecordStored::class, ], \Dnakitare\Outbox\Events\MessageProcessed::class => [ \App\Listeners\Outbox\RecordProcessingDuration::class, ], \Dnakitare\Outbox\Events\MessageFailed::class => [ \App\Listeners\Outbox\PageOnExhaustedFailure::class, ], ];
MessageFailed carries $exhausted: true when the message has just
landed in dead-letter. That's the signal to page a human.
Alternatively, implement Dnakitare\Outbox\Contracts\MetricsCollector
and set its FQCN in outbox.monitoring.metrics_collector.
6. Make your listeners and jobs idempotent
Outbox delivers at-least-once. A message may replay if the worker crashes between dispatching and marking complete. Every listener and job handler must be safe to execute twice.
The simplest pattern: use the correlation_id (available on every
outbox row) as a dedup key in an idempotency_log table or a Redis
SETNX.
7. Tune backoff for your downstream
The default backoff starts at 5s, doubles each attempt, caps at 600s,
and adds full jitter. If your downstream is a fast internal service,
tighten base_seconds. If it's a flaky third-party with long
outages, raise max_seconds. Jitter should almost always be left on.
OUTBOX_BACKOFF_BASE=5 OUTBOX_BACKOFF_MAX=600 OUTBOX_BACKOFF_MULTIPLIER=2.0 OUTBOX_BACKOFF_JITTER=true
8. Rotate the HMAC key carefully
Payloads are signed with OUTBOX_HMAC_KEY (falling back to APP_KEY).
If you rotate the key, all in-flight messages signed with the old key
will fail integrity and dead-letter. Drain the outbox table before
rotating, or dual-sign during a transition window (not yet supported —
issue welcome).
Delivery semantics
- At-least-once. Listeners and job handlers must be idempotent.
- Ordering is preserved within a single transaction. Messages from
the same
Outbox::transaction()call carry asequence_numberand are claimed in order. Across transactions there is no ordering guarantee. - Backoff between retries. Truncated exponential with full jitter.
- Exhaustion → dead-letter. After
max_attemptsthe message is markedfailedand copied tooutbox_dead_letter.
Concurrency
claimPendingMessages() uses SELECT ... FOR UPDATE SKIP LOCKED on
MySQL/Postgres so you can run many workers horizontally without
contention. Each worker sees a disjoint batch. On SQLite (tests only)
it falls back to plain FOR UPDATE, which is correct but serialises.
Operations
# Inspect dead-letter php artisan outbox:inspect-dead-letter php artisan outbox:inspect-dead-letter --id=<uuid> php artisan outbox:inspect-dead-letter --aggregate=Order # Retry failed messages (preserves history) php artisan outbox:retry --all php artisan outbox:retry --id=<uuid1> --id=<uuid2> php artisan outbox:retry --all --purge-history # discards history # Prune php artisan outbox:prune # uses config defaults php artisan outbox:prune --completed-days=3 --dead-letter-days=180
Security
HMAC-signed payloads. Every stored payload is prefixed with an
HMAC-SHA256 tag computed with your APP_KEY (override via
OUTBOX_HMAC_KEY). A tampered payload fails verification at replay
and is sent to dead-letter.
Class allowlist on deserialisation. unserialize() is called with
allowed_classes populated from outbox.serialization.allowed_classes.
A payload referencing a class not on the list lands in dead-letter
rather than rehydrating.
Report security issues privately to the package maintainer rather than via the public issue tracker.
Testing
composer test # pest, against SQLite composer analyse # phpstan level 5 composer format-check # pint composer check # all three
Tests cover unit (service, serializer, backoff), integration (real repository against SQLite), concurrency (disjoint claims), and end-to-end feature tests covering success, retry, backoff, dead-letter, payload tampering, and rehydration failure.
Contributing
Please see CONTRIBUTING.md and CODE_OF_CONDUCT.md.
License
MIT. See LICENSE.