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

Message Outbox For Illuminate Laravel Package

eventsauce/message-outbox-for-illuminate

A Laravel/Illuminate outbox for EventSauce that persists domain messages transactionally alongside your business data, then relays them to consumers, avoiding dual-write problems and helping guarantee reliable, at-least-once event delivery.

View on GitHub
Deep Wiki
Context7

Getting Started

Prerequisites before you touch code. You need three things in place or this package is the wrong tool: a relational database (single DB shared with your write model), EventSauce already in the app (or a willingness to adopt its Message/MessageRepository contracts), and a real downstream consumer outside your DB transaction — a broker, a partner endpoint, another service. If your only consumer is your own Laravel queue, stop and use afterCommit dispatch instead.

Where to look first. Not this repo. This is a [READ ONLY] subtree split of the EventSauce monorepo, so it has no issue tracker, no PR flow, and no README worth reading beyond an install line. Read in this order:

  1. composer require eventsauce/message-outbox-for-illuminate (pin an exact version — see Gotchas).
  2. The src/ directory here. It is small — an MessageOutbox implementation over Illuminate\Database\ConnectionInterface, plus the outbox-backed message repository. Read it end to end in one sitting.
  3. The parent eventsauce/message-outbox package for the abstractions you'll actually program against: MessageOutbox (persist / fetchBatch / markDispatched), MessageRelay (the drain loop + failure handling), and the MessageSerializer contract.
  4. The package's own test suite — it is the de facto documentation. Copy the test bootstrap into a scratch branch to get a working wiring reference.
  5. The upstream EventSauce docs on the transactional outbox pattern, for the conceptual contract (at-least-once, relay, idempotency).

Minimal first wiring. Four moving parts, in this order:

  • Schema. Create the outbox table: a message id, the serialized payload, a serialized-headers column, a nullable dispatched_at, and timestamps. Add an index on (dispatched_at, id). If the package ships a publishable migration, publish and review it; if not, own the schema yourself.
  • Binding. In a service provider, construct the Illuminate outbox with the DB connection, your MessageSerializer, and the table name. Register it as a singleton keyed to the MessageOutbox interface.
  • Repository swap. Replace your aggregate's MessageRepository with the outbox-backed repository this package provides, so persist() writes the messages to the outbox inside whatever transaction is open.
  • Relay entry point. Wrap MessageRelay in an Artisan command (details in Implementation Patterns) and run it under Supervisor/systemd.

First use case to prove it with. Pick one flow where "DB commit succeeded, message was lost" is a user-visible failure — order placed → fulfillment, payment captured → ledger. Instrument it end to end before touching a second flow.

Implementation Patterns

Transaction-boundary pattern (the core one). The outbox insert must land in the same transaction as the aggregate write. EventSauce gives you the shape:

DB::transaction(function () use ($repository, $aggregate) {
    // aggregate row(s) written...
    $repository->persist(...$aggregate->releaseEvents()); // -> outbox insert
});

If you use EventSauce's TransactionalMessageRepository-style interface, persist() runs inline and persistAfterCommit() defers — for outbox semantics you almost always want the inline form, because "after commit" reintroduces the dual-write window you adopted this package to close.

Relay worker pattern. Run the relay as a supervised long-lived process, not a request-scoped job:

protected $signature = 'outbox:relay {--once} {--batch=100} {--sleep=250000}';

public function handle(MessageRelay $relay): int
{
    do {
        $relay->relayBatch((int) $this->option('batch'));
        if ($this->option('once')) return self::SUCCESS;
        usleep((int) $this->option('sleep'));
    } while (true);
}

The critical contract: your dispatcher must throw on publish failure so the relay does not call markDispatched. Swallow an exception there and you have silently created the exact data-loss bug the pattern exists to prevent.

Dispatcher adapter pattern. Plug the broker into EventSauce's dispatcher/consumer contract and keep it dumb — serialize, hand off, return. Everything clever (retries, metrics, DLQ routing) belongs in decorators around it, so the relay's view of "published" stays honest.

Serializer-as-contract pattern. Pick one MessageSerializer and treat its output as a versioned wire format. Persist the serializer name/version alongside the payload if you ever expect to change it, otherwise a serializer upgrade strands every undispatched row.

Shadow-run pattern for adoption. Keep your existing dispatch path; additionally persist to the outbox. Relay the outbox to a no-op consumer and diff the two streams. This finds missing event sites cheaply before you flip anything. Cut over one bounded context at a time, leave the legacy path feature-flagged for a release cycle, then delete it.

Idempotent-consumer pattern. Non-negotiable. Dedupe on the message id (or aggregate_id + aggregate_version) on the consumer side. At-least-once means duplicates are a feature, not an incident.

Testing patterns. Assert on the outbox table, not on dispatcher calls — assertDatabaseHas('message_outbox', ['dispatched_at' => null]) after the transaction, then run one relay batch against a fake dispatcher and assert dispatched_at is set. For the failure path, make the fake dispatcher throw and assert the row stays undispatched.

Operational hooks to add on day one. Outbox depth (count where dispatched_at is null), age of oldest undispatched row, publish-failure counter, and a dead-letter table. Wrap the dispatcher to emit the first three; the relay is the only place that can tell you it's starving.

Gotchas and Tips

You cannot patch this locally. Read-only subtree split, no issue tracker, single upstream maintainer. Pin the exact version in composer.json — a floating constraint lets an upstream monorepo release move your Illuminate constraints under you mid-upgrade. Budget a fork as a real possibility; the code surface is small enough that it's days, not weeks.

Illuminate upgrades are gated on upstream. Laravel 11/12 and PHP 8.2+ support live in the monorepo's tags, not here. Validate framework bumps in a throwaway branch before you commit to them.

The outbox alone is not the pattern. Without a running relay it is just a table that grows and a promise you are not keeping. If the supervisor dies silently the app looks perfectly healthy while consumers starve — alert on outbox depth and oldest-undispatched age, not on process liveness alone.

Duplicate delivery on relay crash. Publish succeeds, process dies before markDispatched, row replays. That's the contract. Any consumer that isn't idempotent is a production incident waiting on your first OOM kill.

Concurrency and locking. Verify explicitly how fetchBatch claims rows. Postgres (FOR UPDATE SKIP LOCKED) supports multiple relay workers cleanly; MySQL variants often don't. If locking is absent or unclear, run exactly one relay worker — a single-threaded relay is a throughput ceiling you can live with, double-delivery from concurrent relays is not.

No global ordering guarantee. Per-aggregate ordering depends on insertion order and relay batching. Design consumers to tolerate out-of-order across aggregates; don't assume a single stream.

Poison messages head-of-line block. One message your dispatcher can never publish stalls everything behind it. Use the relay's failure/exception-handling extension point to quarantine after N attempts into a dead-letter table — and actually exercise that path in staging.

Prune from day one. A scheduled delete where dispatched_at < now() - interval '7 days' (or equivalent) is mandatory. An unpruned outbox degrades write performance for the whole application, not just the messaging path, because every domain write now competes with a bloated insert target.

Long-running worker hygiene. The relay process holds a DB connection for hours. Add DB::reconnect() on a timer or after N batches, gc_collect_cycles() between batches, and cap batch size so a single pass doesn't balloon memory. Bind the relay as a singleton but don't let a stale connection reference outlive a reconnect.

Transaction length is now coupled to messaging. Every aggregate write holds outbox row locks for the length of its transaction. Review long-running transactions before adoption — this package inherits their duration.

Payload sizing. Use LONGTEXT/JSON appropriately for serialized messages and headers; truncation errors here surface as relay failures, not write failures, which is a miserable debugging experience.

Debugging recipe. The outbox table is your queue. Start with select count(*), min(created_at) from message_outbox where dispatched_at is null, then inspect a specific row's serialized payload, then grep consumer logs by message id. If dispatched_at is set but the consumer never saw it, your dispatcher lied — it returned instead of throwing.

Extension points worth knowing. You can implement MessageOutbox yourself (the ~200-line reimplementation is a legitimate buy-vs-build outcome), decorate the dispatcher for metrics/DLQ, replace the serializer, and supply your own relay failure handler. Prefer decorating over forking — a fork is a permanent maintenance tax on a package with no upstream safety net.

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.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log