PHPackages                             dnakitare/laravel-outbox - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Queues &amp; Workers](/categories/queues)
4. /
5. dnakitare/laravel-outbox

ActiveLibrary[Queues &amp; Workers](/categories/queues)

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.

0.1.0-beta2(3mo ago)11MITPHPPHP ^8.2CI passing

Since Apr 19Pushed 1w ago1 watchersCompare

[ Source](https://github.com/Dnakitare/laravel-outbox)[ Packagist](https://packagist.org/packages/dnakitare/laravel-outbox)[ RSS](/packages/dnakitare-laravel-outbox/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (2)Dependencies (14)Versions (3)Used By (0)

Laravel Outbox
==============

[](#laravel-outbox)

[![Tests](https://github.com/dnakitare/laravel-outbox/actions/workflows/test.yml/badge.svg)](https://github.com/dnakitare/laravel-outbox/actions/workflows/test.yml)[![Static analysis](https://github.com/dnakitare/laravel-outbox/actions/workflows/static.yml/badge.svg)](https://github.com/dnakitare/laravel-outbox/actions/workflows/static.yml)[![Latest version](https://camo.githubusercontent.com/0c2a1029534184d6c4d05a2f04825e60d0dce46f9f19d03cd2e1a60a48688a30/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f646e616b69746172652f6c61726176656c2d6f7574626f782e7376673f696e636c7564655f70726572656c6561736573)](https://packagist.org/packages/dnakitare/laravel-outbox)[![License](https://camo.githubusercontent.com/5f8fbef77e46ca40f399e3ab278d9070bc60e08d96a79dd65fd0d7f45c0fffdc/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f6c2f646e616b69746172652f6c61726176656c2d6f7574626f782e737667)](LICENSE)

> **⚠️ Beta software.** This package is at `0.1.0-beta2` and has not yet been battle-tested in production. Adopt with eyes open, file issues generously. We commit to strict SemVer starting at `1.0.0`.

A careful implementation of the Transactional Outbox Pattern for Laravel, built to production standards but not yet proven at scale.

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
------------

[](#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
------------

[](#installation)

```
composer require dnakitare/laravel-outbox
php artisan vendor:publish --tag=outbox-config
php artisan vendor:publish --tag=outbox-migrations
php artisan migrate
```

Usage
-----

[](#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
--------------------

[](#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

[](#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

[](#2-run-the-worker-from-the-scheduler)

Pick one pattern. Do not mix them or 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

[](#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

[](#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

[](#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

[](#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

[](#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

[](#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
------------------

[](#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 a `sequence_number` and are claimed in order. Across transactions there is no ordering guarantee.
- **Backoff between retries.** Truncated exponential with full jitter.
- **Exhaustion → dead-letter.** After `max_attempts` the message is marked `failed` and copied to `outbox_dead_letter`.

Concurrency
-----------

[](#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
----------

[](#operations)

```
# Inspect dead-letter
php artisan outbox:inspect-dead-letter
php artisan outbox:inspect-dead-letter --id=
php artisan outbox:inspect-dead-letter --aggregate=Order

# Retry failed messages (preserves history)
php artisan outbox:retry --all
php artisan outbox:retry --id= --id=
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
--------

[](#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
-------

[](#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
------------

[](#contributing)

Please see [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE\_OF\_CONDUCT.md](CODE_OF_CONDUCT.md).

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

35

—

LowBetter than 77% of packages

Maintenance91

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community7

Small or concentrated contributor base

Maturity33

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

97d ago

PHP version history (2 changes)0.1.0-beta1PHP ^8.1

0.1.0-beta2PHP ^8.2

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/42360412?v=4)[Daniel Nakitare](/maintainers/Dnakitare)[@Dnakitare](https://github.com/Dnakitare)

---

Top Contributors

[![Dnakitare](https://avatars.githubusercontent.com/u/42360412?v=4)](https://github.com/Dnakitare "Dnakitare (41 commits)")

---

Tags

distributed-systemsevent-drivenlaravellaravel-packagemessage-queuemicroservicesoutboxoutbox-patternphpreliabilitytransactional-outboxlaraveleventsjobsmicroservicesdistributed systemstransactional-outboxoutboxreliability

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/dnakitare-laravel-outbox/health.svg)

```
[![Health](https://phpackages.com/badges/dnakitare-laravel-outbox/health.svg)](https://phpackages.com/packages/dnakitare-laravel-outbox)
```

###  Alternatives

[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M246](/packages/laravel-ai)[illuminate/queue

The Illuminate Queue package.

20432.6M1.7k](/packages/illuminate-queue)[psalm/plugin-laravel

Psalm plugin for Laravel

3345.3M347](/packages/psalm-plugin-laravel)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.5k55.4M9.2k](/packages/larastan-larastan)[spatie/laravel-health

Monitor the health of a Laravel application

87912.0M177](/packages/spatie-laravel-health)[api-platform/laravel

API Platform support for Laravel

58174.6k17](/packages/api-platform-laravel)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
