Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Outbox Bundle Laravel Package

micro-module/outbox-bundle

A Laravel bundle implementing the transactional outbox pattern: persist domain events to a database table inside the same transaction as your writes, then relay them reliably to queues or brokers. Avoids lost or duplicated messages when publishing fails, keeping event delivery consistent with commit...

View on GitHub
Deep Wiki
Context7

Getting Started

First, calibrate your expectations. micro-module/outbox-bundle is a Symfony bundle (MicroModule\OutboxBundle namespace, *Bundle class, Resources/config/services.yaml), not a Laravel package. There is no OutboxBundleServiceProvider, no facade, no config publish tag. At 0 stars / 0 dependents / no release history, treat it as a reference implementation of the transactional outbox pattern rather than a dependency you composer require into a Laravel app.

Read these first, in order:

  1. composer.json — confirms which Symfony components and Doctrine versions it pins. This tells you how portable the internals are.
  2. src/*Bundle.php — the DI extension and compiler passes; this is where the "which services get tagged as relay/publisher" wiring lives.
  3. The Doctrine entity + its mapping (XML/YAML/attributes under Resources/config/doctrine or src/Entity) — this is the table schema you'd port to an Eloquent migration verbatim.
  4. The relay/consumer console command — this is the piece you'd rewrite as an Artisan command.

First use case to prototype: an order is placed; the OrderPlaced event must reach your queue only if the order row commits. Instead of event(new OrderPlaced($order)) inside a transaction (which can fire for a rolled-back order, or be lost if the queue is down), you insert an outbox row in the same transaction and let a relay publish it afterwards.

The minimum viable port looks like this:

Schema::create('outbox_messages', function (Blueprint $t) {
    $t->bigIncrements('id');
    $t->uuid('message_id')->unique();          // consumer-side dedupe key
    $t->string('aggregate_type');
    $t->string('aggregate_id');
    $t->string('event_type');
    $t->json('payload');
    $t->string('status')->default('pending');  // pending|processing|done|failed
    $t->unsignedInteger('attempts')->default(0);
    $t->text('last_error')->nullable();
    $t->timestamp('available_at')->nullable(); // for backoff
    $t->timestamp('processed_at')->nullable();
    $t->timestamps();
    $t->index(['status', 'available_at', 'id']);
});

Then a single write path:

DB::transaction(function () use ($order) {
    $order->save();
    OutboxMessage::create([
        'message_id'   => (string) Str::uuid(),
        'aggregate_type' => 'order',
        'aggregate_id' => $order->id,
        'event_type'   => 'order.placed',
        'payload'      => $order->toEventPayload(),
        'available_at' => now(),
    ]);
});

Implementation Patterns

Pattern 1 — Write-in-transaction, relay-after-commit (the core). Never call dispatch()/event() for cross-module or cross-service events from a controller. Emit them via the outbox inside the same DB::transaction(). This is the whole point of the pattern; if the outbox insert happens outside the transaction, you've just re-implemented the dual-write problem.

Pattern 2 — Thin emitting helper. Wrap the insert so call sites stay readable and the convention is enforced:

final class Outbox
{
    public static function record(string $eventType, string $aggregateType, string $aggregateId, array $payload): void
    {
        OutboxMessage::create([
            'message_id'     => (string) Str::uuid(),
            'event_type'     => $eventType,
            'aggregate_type' => $aggregateType,
            'aggregate_id'   => $aggregateId,
            'payload'        => $payload,
            'available_at'   => now(),
        ]);
    }
}

Call it from inside the transaction, next to the state change:

DB::transaction(function () use ($order) {
    $order->save();
    Outbox::record('order.placed', 'order', $order->id, $order->toEventPayload());
});

Pattern 3 — Polling relay as a scheduled Artisan command. The Symfony bundle ships a console relay; port it to outbox:relay, scheduled every minute via Kernel::schedule. Use lockForUpdate + SKIP LOCKED so multiple workers don't double-process:

OutboxMessage::where('status', 'pending')
    ->where('available_at', '<=', now())
    ->orderBy('id')
    ->limit(200)
    ->lockForUpdate()
    ->get()
    ->each(function (OutboxMessage $m) {
        try {
            dispatch(new PublishToBroker($m->event_type, $m->payload))->onQueue('outbox');
            $m->update(['status' => 'done', 'processed_at' => now()]);
        } catch (\Throwable $e) {
            $m->increment('attempts');
            $m->update([
                'last_error'   => $e->getMessage(),
                'available_at' => now()->addSeconds(2 ** min($m->attempts, 6)),
                'status'       => $m->attempts >= 5 ? 'failed' : 'pending',
            ]);
        }
    });

Pattern 4 — Consumer-side idempotency. The bundle's whole guarantee is at-least-once; your consumers must own the other half. In every queued listener, guard on message_id:

public function handle(array $payload, string $messageId): void
{
    if (! ProcessedMessage::insertIgnore($messageId)) {
        return; // already handled
    }
    // ... do the work
}

Pattern 5 — Ordered consumers per aggregate. For events that need ordering (order lifecycle state transitions), key the queue job on aggregate_type:aggregate_id (e.g. Horizon/Laravel queued job onQueue("aggregate-{$type}-{$id}")) so partitions serialize. Cross-aggregate events don't need ordering.

Pattern 6 — Adopt at module boundaries only. Retrofitting your entire event system is a mistake. Scope the outbox to events that cross a module or service edge: OrderModule → InventoryModule, or out of the app to Kafka/RabbitMQ. Internal same-module events can stay as Laravel events.

Pattern 7 — Use the Symfony bundle as a side-car service when you genuinely need it. If you inherit a Symfony service and want to keep the outbox behavior, run it separately and bridge via the broker. Don't try to bend it into Laravel — the DI container, Doctrine\ORM\EntityManager, and MessageHandlerInterface semantics don't survive the boundary cleanly.

Gotchas and Tips

It is not a Laravel package — do not composer require it into a Laravel app hoping for auto-discovery. Composer may install it (Symfony components are pullable), but nothing registers. You will be writing the entire Laravel integration yourself; at that point you've re-implemented the package. Either run the Symfony app with it genuinely installed, or copy the migration + entity out as a starting point and implement the relay natively.

At-least-once, not exactly-once. The bundle gives you delivery, not deduplication. Every consumer needs an idempotency key. If you deploy without one, expect duplicate order confirmations, double-charged payments, and duplicate emails — the classic outbox failure mode. The message_id UUID in the outbox row is the key you emit as a header/field for consumers to dedupe on.

Transaction-boundary bugs are silent. Putting the outbox insert outside the DB::transaction() compiles fine, runs fine, and defeats the pattern. Make it a code-review checklist item: "is the outbox insert inside a transaction with the state change?" If you want to enforce it, add a test hook that throws if Outbox::record() runs with no active transaction.

Table growth becomes your problem in week 2, not month 6. A polling relay needs WHERE status='pending' AND available_at <= now(), which is only fast with a compound index. And the table grows monotonically — ship a outbox:prune command that deletes status='done' rows older than N days, on the schedule, from day 1. If you don't, the first slow query you investigate will be the relay.

Don't run dispatch()->onQueue() inside the relay loop without accounting for double-dispatch. The lockForUpdate holds the row while the job pushes; if the worker crashes between push and status='done' update, the message is re-pushed — that's expected (at-least-once) — which is why consumer idempotency is non-negotiable. Alternative: move status='processing' in the same transaction as the broker publish, but that only works with a transactional broker (Kafka, RabbitMQ with publisher confirms). For anything else, accept the duplicate.

SKIP LOCKED availability. MySQL 8.0+ and PostgreSQL support it; MariaDB <10.6 and MySQL 5.7 do not. Without SKIP LOCKED you'll get either lock contention or duplicate processing when you scale to two relay workers. Verify your DB version before you plan to run more than one worker.

Scheduled relay = polling latency. Expect up to your schedule interval of delivery delay. For a 1-minute cron that's fine for notifications and analytics; it's not fine for real-time UX where the user is waiting on a downstream side effect. For that, dispatch the relay inline after commit via DB::afterCommit() as a fast path — but never rely on it alone; the polling relay is your safety net.

Poison messages will block the head-of-line if you order strictly by id and only advance a pointer. Prefer per-row attempts + available_at (as above) so a stuck message retries in the background while others proceed. Escalate to a failed status after N attempts and alarm on the count of failed rows — otherwise failed rows sit silently forever.

Broker outage backs up the outbox, which backs up the app. If the relay's publish blocks on the broker, the outbox grows and any lockForUpdate queries near the relay can contend with the write path. Give the relay its own connection or at minimum cap the batch size and run it in short transactions; don't hold a lock across an HTTP call to a broker that's timing out.

Multi-tenant / multi-connection. The single-outbox-table default is fine for single-DB apps. If you use per-tenant DBs or multiple connections, you need an outbox table (and a relay process, or a per-tenant scope) per connection — otherwise the atomicity guarantee breaks. The Symfony bundle's DI approach does not abstract this for you; verify in your spike.

Where to extend, where not to. When porting, keep the outbox entity dumb (no domain logic, no relations). Keep the relay a thin publisher — the temptation is to add business logic in the relay "because it already has the event," and that's how you get a second, invisible application. Use the outbox purely as a transport mechanism.

Budget for internal documentation. At 0 stars there are no StackOverflow answers, no issue history, no community examples. Write the internal convention doc (when to use the outbox, idempotency requirement, retention policy, relay alarm thresholds) before the second engineer touches it. Otherwise the pattern will be applied inconsistently and the inconsistency will be visible only in production incidents.

Pin it, or fork it. If you're genuinely using the Symfony bundle (rather than using it as reference), pin an exact commit hash in composer.json and wrap it behind a thin internal interface. With single-maintainer abandonment risk, you want to be able to swap the implementation without touching call sites.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect