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.
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:
composer require eventsauce/message-outbox-for-illuminate (pin an exact version — see Gotchas).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.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.Minimal first wiring. Four moving parts, in this order:
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.MessageSerializer, and the table name. Register it as a singleton keyed to the MessageOutbox interface.MessageRepository with the outbox-backed repository this package provides, so persist() writes the messages to the outbox inside whatever transaction is open.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.
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.
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.
How can I help you explore Laravel packages today?