PHPackages                             gpalyan/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. gpalyan/laravel-outbox

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

gpalyan/laravel-outbox
======================

Laravel package for Transactional Outbox/Inbox patterns

v2.0.0(1w ago)110MITPHPPHP ^8.4CI passing

Since May 18Pushed 1w agoCompare

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

READMEChangelogDependencies (18)Versions (6)Used By (0)

Laravel package implementing the **Transactional Outbox / Inbox** patterns. Broker-agnostic: ship messages to any transport (NATS, Kafka, RabbitMQ, SQS, HTTP webhook — anything you can call `publish()` on).

Contents
--------

[](#contents)

- [Delivery semantics](#delivery-semantics)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Outbox — publishing messages](#outbox--publishing-messages)
    - [1. Write to the outbox inside your DB transaction](#1-write-to-the-outbox-inside-your-db-transaction)
    - [2. Implement `OutboxPublisherInterface` for your broker](#2-implement-outboxpublisherinterface-for-your-broker)
    - [3. Bind it in the service container](#3-bind-it-in-the-service-container)
    - [4. Schedule the publisher worker](#4-schedule-the-publisher-worker)
- [Inbox — receiving messages](#inbox--receiving-messages)
    - [1. From your broker subscriber, fire `MessageConsumed`](#1-from-your-broker-subscriber-fire-messageconsumed)
    - [2. Implement a handler per channel](#2-implement-a-handler-per-channel)
    - [3. Bind channel → handler in the container](#3-bind-channel--handler-in-the-container)
    - [4. Schedule the inbox worker](#4-schedule-the-inbox-worker)
- [Pruning old rows](#pruning-old-rows)
- [How it works](#how-it-works)
- [Public surface](#public-surface)
- [Client responsibilities](#client-responsibilities)

Delivery semantics
------------------

[](#delivery-semantics)

The package gives you **at-least-once delivery to the broker** plus **idempotent storage on both sides keyed on a caller-supplied `deduplication_key`**. Composed together, this is what is commonly called **effectively-exactly-once**:

- **Outbox side.** `OutboxMessage::store()` is a `firstOrCreate` keyed on the `deduplicationKey` you pass. Calling it twice with the same key inside a retried HTTP request or a retried job stores one row, not two.
- **Publisher worker.** Keeps retrying with exponential backoff until your `OutboxPublisherInterface::publish()` returns without throwing. If the broker accepts the message but the ack is lost, the worker will publish again — downstream **must** be idempotent. This is fundamental to any outbox; the package does not paper over it.
- **Inbox side.** `InboxMessage::store()` is also `firstOrCreate` on the key you pass — typically the **broker message id**. Re-deliveries of the same message land on the same row, the handler runs once.

True exactly-once delivery to a remote broker is impossible (FLP, two-generals); don't promise it. What you can promise to downstream consumers is **effectively-exactly-once processing of each logical message**, which is what this package implements.

> **The deduplication key is yours, by design.** The identity of a logical message — "is this the same event or a new one?" — is domain knowledge only the caller has; the package cannot infer it and does not try. You pass an explicit `deduplicationKey` (e.g. `order.created:42`, or `order.payout:42:attempt-2` when a second payout is intended). Make it stable for the same logical event and distinct for genuinely different ones.
>
> If you *want* content-addressed dedup (one row per unique payload), opt in explicitly with `OutboxMessage::hashPayload($payload)` as the key — but note it only holds if the payload is canonical (no timestamps / random fields), otherwise duplicates hash differently and won't dedup.

Requirements
------------

[](#requirements)

- PHP 8.4+
- Laravel 12 / 13

Installation
------------

[](#installation)

```
composer require gpalyan/laravel-outbox
```

Publish config and migrations:

```
php artisan vendor:publish --tag=transactional-outbox-config
php artisan vendor:publish --tag=transactional-outbox-migrations
php artisan migrate
```

Configuration
-------------

[](#configuration)

VariableDefaultDescription`OUTBOX__RETRY_BACKOFF``2`Exponential backoff multiplier`OUTBOX__RETRY_JITTER``0.2`Random jitter factor (0–1)`OUTBOX__RETRY_MAX_DELAY``86400`Max delay between publish retries (seconds)`OUTBOX__IN_PROGRESS_DEADLINE``60`Seconds before an in-progress outbox row is considered stuck`OUTBOX__PRUNE_AFTER_DAYS``30`Days to keep sent messages before pruning`INBOX__MAX_ATTEMPTS``5`Max handler invocations before permanent failure`INBOX__RETRY_DELAY_SECONDS``15`Initial retry delay`INBOX__MAX_DELAY_SECONDS``3600`Max delay between handler retries`INBOX__IN_PROGRESS_DEADLINE``300`Seconds before an in-progress inbox row is considered stuck`INBOX__PRUNE_AFTER_DAYS``30`Days to keep processed messages before pruningOutbox — publishing messages
----------------------------

[](#outbox--publishing-messages)

### 1. Write to the outbox inside your DB transaction

[](#1-write-to-the-outbox-inside-your-db-transaction)

```
use TransactionalOutbox\Models\OutboxMessage;

DB::transaction(function () use ($order) {
    $order->save();

    OutboxMessage::store(
        channel: 'orders.created',
        payload: json_encode($order),
        deduplicationKey: "order.created:{$order->id}",
        headers: ['X-Type' => 'OrderCreated'], // optional
    );
});
```

Batch insert (skips Eloquent events, useful for bulk producers). Each item is an `OutboxDraft` — the deduplication key is a required constructor argument, so it cannot be silently omitted for any message in the batch:

```
use TransactionalOutbox\Data\OutboxDraft;

OutboxMessage::storeBatch([
    new OutboxDraft(
        channel: 'orders.created',
        payload: json_encode($order1),
        deduplicationKey: "order.created:{$order1->id}",
    ),
    new OutboxDraft(
        channel: 'orders.updated',
        payload: json_encode($order2),
        deduplicationKey: "order.updated:{$order2->id}:{$order2->version}",
    ),
]);
```

Duplicate keys inside a single batch (or against rows already stored) are skipped by the unique index — no need to pre-check the array yourself.

### 2. Implement `OutboxPublisherInterface` for your broker

[](#2-implement-outboxpublisherinterface-for-your-broker)

Below is a complete working example using RabbitMQ via `php-amqplib/php-amqplib`. Replace with your broker SDK; the contract is the same: take an `OutboxMessage`, publish it, throw on failure.

```
namespace App\Messaging;

use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
use PhpAmqpLib\Wire\AMQPTable;
use RuntimeException;
use TransactionalOutbox\Contracts\OutboxPublisherInterface;
use TransactionalOutbox\Models\OutboxMessage;

final class RabbitMqOutboxPublisher implements OutboxPublisherInterface
{
    public function __construct(private AMQPStreamConnection $connection) {}

    public function publish(OutboxMessage $message): void
    {
        $route = config("rabbitmq.routes.{$message->channel}");

        if (! $route) {
            throw new RuntimeException("No RabbitMQ route configured for channel '{$message->channel}'");
        }

        $amqpMessage = new AMQPMessage(
            $message->payload,
            [
                'content_type' => 'application/octet-stream',
                'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
                'message_id' => $message->id,
                'application_headers' => new AMQPTable($message->headers ?? []),
            ],
        );

        $channel = $this->connection->channel();
        $channel->basic_publish(
            msg: $amqpMessage,
            exchange: $route['exchange'],
            routing_key: $route['routing_key'],
        );
        // Any AMQP exception here propagates up; the worker catches it and reschedules with backoff.
    }
}
```

Route table lives in your own config:

```
// config/rabbitmq.php
return [
    'routes' => [
        'orders.created' => ['exchange' => 'orders', 'routing_key' => 'orders.created'],
        'orders.updated' => ['exchange' => 'orders', 'routing_key' => 'orders.updated'],
        'payments.processed' => ['exchange' => 'payments', 'routing_key' => 'payments.processed'],
    ],
];
```

### 3. Bind it in the service container

[](#3-bind-it-in-the-service-container)

```
// AppServiceProvider::register()
$this->app->singleton(AMQPStreamConnection::class, fn () => new AMQPStreamConnection(
    host: config('rabbitmq.host'),
    port: config('rabbitmq.port'),
    user: config('rabbitmq.user'),
    password: config('rabbitmq.password'),
));

$this->app->bind(
    \TransactionalOutbox\Contracts\OutboxPublisherInterface::class,
    \App\Messaging\RabbitMqOutboxPublisher::class,
);
```

### 4. Schedule the publisher worker

[](#4-schedule-the-publisher-worker)

```
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('transactional-outbox:process outbox')
    ->everyMinute()
    ->withoutOverlapping();
```

Inbox — receiving messages
--------------------------

[](#inbox--receiving-messages)

### 1. From your broker subscriber, fire `MessageConsumed`

[](#1-from-your-broker-subscriber-fire-messageconsumed)

The package does **not** subscribe to any broker. You bring your own subscriber (a long-running artisan command, a daemon, a worker subscribed to push notifications — whatever fits your broker). For each message received, fire the package event.

A complete RabbitMQ subscriber as an artisan command:

```
namespace App\Console\Commands;

use Illuminate\Console\Command;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
use Throwable;
use TransactionalOutbox\Events\MessageConsumed;

final class RabbitMqListenCommand extends Command
{
    protected $signature = 'app:rabbitmq-listen {queue} {channel}';

    public function handle(AMQPStreamConnection $connection): int
    {
        $amqpChannel = $connection->channel();
        $amqpChannel->basic_qos(prefetch_size: 0, prefetch_count: 10, a_global: false);

        $amqpChannel->basic_consume(
            queue: $this->argument('queue'),
            consumer_tag: '',
            no_local: false,
            no_ack: false,             // argument('channel'),
                        payload: $msg->getBody(),
                        deduplicationKey: $msg->get('message_id'), // broker message id
                        headers: $this->extractHeaders($msg),
                    ));

                    $msg->ack();
                } catch (Throwable $e) {
                    // event() failed (DB unavailable, etc.) — return the message to the broker.
                    $msg->reject(requeue: true);
                    report($e);
                }
            },
        );

        while ($amqpChannel->is_consuming()) {
            $amqpChannel->wait();
        }

        return self::SUCCESS;
    }

    private function extractHeaders(AMQPMessage $msg): array
    {
        $headers = $msg->get_properties()['application_headers'] ?? null;

        return $headers?->getNativeData() ?? [];
    }
}
```

Run one process per (queue, channel) pair under supervisor / systemd:

```
php artisan app:rabbitmq-listen orders.queue orders.created

```

The package registers `OnMessageConsumed` to this event automatically. The listener stores the message in `inbox_messages` (idempotent on the `deduplicationKey` you supply — use the broker message id so broker re-deliveries dedup).

**Synchronicity contract — important.**

`event(new MessageConsumed(...))` invokes the listener **synchronously**, in the same PHP process and call stack as your subscriber. The listener performs a DB write before `event()` returns. Implications:

- **Ack the broker only after `event()` returns.** If you ack first and `event()` then throws (DB unavailable, constraint failure, etc.), the inbox row is not persisted but the broker considers the message delivered — silent loss. The correct order is: receive → fire `event()` → on success, ack; on exception, nack/let the broker redeliver. This is what makes the system at-least-once end-to-end.
- **Do not wrap the listener in `Event::fake()` in tests** unless you re-fake selectively. `Event::fake()` with no arguments swallows all events, including `MessageConsumed`, and your inbox table stays empty. Use `Event::fake([SomeUnrelatedEvent::class])` to fake specific events only, or assert against the inbox row directly.
- **Do not queue the listener.** If you ever make `OnMessageConsumed` implement `ShouldQueue`, the listener becomes async — `event()` returns before the DB write, you can ack the broker and then lose the message if the queue or DB fails. The default registration is intentionally synchronous.

### 2. Implement a handler per channel

[](#2-implement-a-handler-per-channel)

```
use TransactionalOutbox\Contracts\InboxHandlerInterface;
use TransactionalOutbox\Models\InboxMessage;

final class PaymentProcessedHandler implements InboxHandlerInterface
{
    public function handle(InboxMessage $message): void
    {
        $data = json_decode($message->payload, true, flags: JSON_THROW_ON_ERROR);
        // process $data... throw on failure — the job will retry automatically
    }
}
```

### 3. Bind channel → handler in the container

[](#3-bind-channel--handler-in-the-container)

```
// AppServiceProvider::register()
$this->app->bind('payments.processed', PaymentProcessedHandler::class);
```

The key passed to `bind()` must match the `channel` you put into `MessageConsumed`.

### 4. Schedule the inbox worker

[](#4-schedule-the-inbox-worker)

```
// routes/console.php
Schedule::command('transactional-outbox:process inbox')
    ->everyMinute()
    ->withoutOverlapping();
```

Pruning old rows
----------------

[](#pruning-old-rows)

```
// routes/console.php
use TransactionalOutbox\Models\OutboxMessage;
use TransactionalOutbox\Models\InboxMessage;

Schedule::command('model:prune', ['--model' => [OutboxMessage::class, InboxMessage::class]])
    ->daily();
```

`prune_after_days` in config controls the cutoff. **Only successfully terminated rows are pruned** — `SENT` on the outbox side, `PROCESSED` on the inbox side. `FAILED` rows are kept indefinitely.

### Why `FAILED` rows are never auto-pruned

[](#why-failed-rows-are-never-auto-pruned)

A `FAILED` row means the package exhausted retries and gave up. Two cases:

- **Outbox `FAILED`** — the publisher could not hand the message off to the broker at all (broker unavailable, route misconfigured, payload rejected). The message **never reached** any broker DLQ — this is your application-side dead letter.
- **Inbox `FAILED`** — your handler kept throwing past `max_attempts`. The message was received from the broker but cannot be processed locally. Again, a dead letter at the application boundary.

In both cases the row is the **only surviving evidence** of a lost business event. Auto-pruning it silently erases problems that need human eyes — broken routes, bad payloads, handler bugs. So the package keeps them.

How it works
------------

[](#how-it-works)

```
Write side (Outbox)                          Read side (Inbox)
──────────────────────────────────────       ──────────────────────────────────────
App calls OutboxMessage::store()             Your broker subscriber receives a msg
  └─ saved in DB (same transaction)            └─ fires event(new MessageConsumed(...))
                                                  └─ OnMessageConsumed listener (built in)
transactional-outbox:process outbox                  └─ InboxMessage::store() (idempotent)
  └─ picks pending outbox rows
  └─ dispatches ProcessOutboxMessageJob       transactional-outbox:process inbox
       └─ resolves OutboxPublisherInterface    └─ picks pending inbox rows
       └─ publisher->publish($message)         └─ dispatches ProcessInboxMessageJob
       └─ marks as sent                            └─ resolves handler from container by channel
                                                   └─ handler->handle($message)
                                                   └─ marks as processed

```

Both sides use exponential backoff on failure with configurable max attempts. Stuck in-progress rows (worker crashed mid-flight) are returned to pending after the configured deadline.

Public surface
--------------

[](#public-surface)

SymbolPurpose`TransactionalOutbox\Models\OutboxMessage`Store outgoing messages inside your DB transaction`TransactionalOutbox\Models\InboxMessage`(read-only from your code) Inbound message rows`TransactionalOutbox\Contracts\OutboxPublisherInterface`Implement to publish to your broker`TransactionalOutbox\Contracts\InboxHandlerInterface`Implement per channel to process inbound messages`TransactionalOutbox\Events\MessageConsumed`Fire from your broker subscriber to push into the inbox`TransactionalOutbox\Events\OutboxMessageSent`Dispatched after successful publish`TransactionalOutbox\Events\OutboxMessageFailed`Dispatched after permanent publish failure`TransactionalOutbox\Events\InboxMessageProcessed`Dispatched after successful handler invocation`TransactionalOutbox\Events\InboxMessageFailed`Dispatched after permanent handler failureArtisan: `transactional-outbox:process outbox|inbox`The worker entrypointClient responsibilities
-----------------------

[](#client-responsibilities)

ResponsibilityWhoProvision broker (streams, topics, queues, subscriptions)YouCall `OutboxMessage::store()` inside DB transactionsYouSupply a stable, logical `deduplicationKey` per messageYouImplement and bind `OutboxPublisherInterface`YouImplement and bind `InboxHandlerInterface` per channelYouRun a broker subscriber that fires `MessageConsumed`YouSchedule `transactional-outbox:process outbox` and `... inbox`YouEnforcing dedup on the key, storage, retries, backoff, pruning, transitionsPackage

###  Health Score

45

—

FairBetter than 91% of packages

Maintenance98

Actively maintained with recent releases

Popularity9

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity55

Maturing project, gaining track record

 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 ~12 days

Total

2

Last Release

11d ago

Major Versions

v1.0.0 → v2.0.02026-05-30

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/79068666?v=4)[Gai Palyan](/maintainers/GaiPalyan)[@GaiPalyan](https://github.com/GaiPalyan)

---

Top Contributors

[![GaiPalyan](https://avatars.githubusercontent.com/u/79068666?v=4)](https://github.com/GaiPalyan "GaiPalyan (1 commits)")

---

Tags

laravelevent-drivenmessaginginboxoutbox

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

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

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

###  Alternatives

[larastan/larastan

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

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k14.1M120](/packages/laravel-pulse)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[illuminate/queue

The Illuminate Queue package.

20432.2M1.5k](/packages/illuminate-queue)[spatie/laravel-health

Monitor the health of a Laravel application

87311.3M149](/packages/spatie-laravel-health)[laravel/pennant

A simple, lightweight library for managing feature flags.

58213.1M81](/packages/laravel-pennant)

PHPackages © 2026

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