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

ianfoxdev/outbox

A transactional outbox for Laravel: write messages or events to a database table inside your existing transaction, then relay them to queues or external services afterward, ensuring reliable delivery and no lost events if a job fails or the app crashes mid-request.

View on GitHub
Deep Wiki
Context7

Getting Started

This is the 1.0.0 stable release of a transactional outbox package: you write a domain row and an outbox message in the same database transaction, and a relay process publishes those messages to your broker afterwards. That buys you atomicity between "business state changed" and "event was emitted" without distributed transactions. The relay publishes to Kafka (it's built on franz-go).

1.0.0 matters for you in one specific way: the freeze that was a stated intent in rc1 is now a contract. The PHP API, the table, the relay's settings, lock key, messages, metrics and endpoints — everything ADR 0008 lists — changes only in a major version from here. No bug was reported against rc1, and the API is unchanged from it, so anything built against rc1 carries forward with no code changes; only the relay image moved.

First steps:

  1. Install and publish: composer require IanFoxDev/outbox (verify the exact constraint in the README — a ^1.0 caret is now reasonable, since breaking changes require a major), then publish config and migrations via the package's vendor:publish tags. Read the generated config/outbox.php before touching anything else — the relay's connection, lock key and partitioning knobs live there and are frozen for the life of 1.x.
  2. Run the migration and confirm the outbox table name/shape matches ADR 0008. If you already have a partitioned messages table of your own, reconcile it now; the layout will not change under you.
  3. Run the relay against your dev broker and send one message end to end. The first use case to prove is the boring one: a single Eloquent model save plus an outbox record inside DB::transaction(), relay picking it up, consumer receiving it once. Prove that before wiring in fan-out, ordering or partition keys.

Where to look first: ADR 0008 (the freeze list doubles as a feature inventory — PHP API, table, relay settings, lock key, message layout, partitioning, metrics, endpoints), then examples/demo — the sample shop that ran the release candidate for six days and 775k orders, which is the fastest way to see a correct producer/relay/consumer wiring end to end — then the config file, then the relay command's signature.

Implementation Patterns

Transactional write is the whole point. Always create the outbox record inside the same DB::transaction() as the state change. A pattern that reads well day to day is a small RecordsOutboxEvents trait or an explicit Outbox::record() call at the end of an application service method — keep it at the service/action layer rather than in model observers, because observers fire on saves you didn't intend to publish (seeders, imports, admin fixes, saveQuietly misuse) and you lose the transactional boundary.

Don't publish from the request path. The relay is the publisher. Treat the outbox table as the queue of record and keep broker calls out of your controller/consumer code. Your HTTP latency is decoupled from broker availability, which is the main operational win.

Relay as a supervised worker. Run it under Horizon-style supervision or a systemd/Supervisor unit with restart-on-failure — and in Kubernetes, as a single-replica Deployment rather than something that scales on CPU. The frozen lock key implies one active relay per lock, so size concurrency accordingly: one relay, scale the consumer side instead. Leader handover is well exercised now (the demo run took 25 leader kills across six days and still reconciled to the cent), so restarts and pod evictions are a supported, boring event — but a second relay contending for the lock is still not a scaling strategy.

Batch and backfill deliberately. For bulk operations (imports, migrations, backfills), emit outbox rows in chunks inside larger transactions and let the relay drain. Avoid one enormous transaction that writes 500k outbox rows; watch the table grow and rely on the partitioning/retention story rather than ad-hoc DELETE jobs, since the table layout is frozen and custom cleanup may fight the intended mechanism.

Consumers should be idempotent. Outbox gives you at-least-once in practice — a crash between broker ack and outbox status update replays. The demo's "shop and consumer agree to the cent" is evidence the mechanism holds under leader churn, not a licence to skip idempotency in your own consumer. Key on the message's identity field from the frozen message layout, and keep a processed-ids table or use an upsert.

Testing. In tests, either run the relay synchronously (call the relay's handler directly, or dispatch the job inline) or assert on outbox rows after the transaction and drain in a setup step. Prefer asserting "row exists with expected payload" over mocking the broker — it keeps tests fast and honest about the contract.

Observability wiring. The release notes call out metrics and endpoints as frozen surface. Point your existing Prometheus/scrape config at the metrics endpoint and add alerts on relay lag (oldest unpublished row age) and unpublished-row count. Lag, not error count, is the signal that something is wrong. Use the endpoints for readiness/liveness of the relay rather than inventing your own heartbeat.

Integration tip: if you already use Laravel's afterCommit dispatch or DB::afterCommit, resist layering it on top — the outbox already solves the same problem more durably, and mixing the two gives you two competing delivery paths for the same event.

Gotchas and Tips

  • The freeze list is now a real compatibility contract. PHP API, table, relay settings, lock key, message layout, partitioning, metrics and endpoints change only on a major. Anything not on that list (internal classes, helper methods, config keys outside the relay settings) can still move in a 1.x minor — don't build on internals that merely look convenient, and don't assume a minor bump is entirely risk-free just because the headline surface is frozen.
  • The relay image is versioned separately from the PHP package. The image carries a floating 1 tag that follows the latest 1.x, and the shipped Kubernetes base pins 1.0. If you need reproducible deploys, pin an exact tag or a digest in your own manifests rather than riding 1; the floating tag means a relay upgrade can land without any change in your composer.json.
  • Upgrading from rc1 is a relay-image bump, not a code change. The PHP API, table and relay settings are identical to 1.0.0-rc1. If you were pinned exactly to rc1, relax to ^1.0 and roll the image; check UPGRADE.md at each future major, not each minor.
  • Long transactions break the relay's assumptions. A transaction held open for seconds means the outbox row is invisible to the relay until commit, then appears in a burst. Keep write transactions short; do slow I/O outside them.
  • Lock key collisions. If you run multiple apps/environments against the same cache or database, the frozen lock key means two relays can contend or, worse, one silently blocks the other. Give each environment its own cache store or key namespace. When the relay appears "stuck" with no errors, check the lock first — a stale lock after an ungraceful kill (SIGKILL, node loss, not a rolling restart) is the classic failure.
  • Table growth is the operational risk. Outbox tables grow with traffic and are read by the relay forever. Confirm your retention/pruning approach (partition drop vs. delete) matches what partitioning supports before the table is huge; retrofitting is where teams get hurt.
  • Ordering is per-partition, not global. If partition keying is what you expect, verify it — assuming global ordering from an outbox is the most common design mistake. Consumers that need ordering must be keyed consistently by the producer.
  • Dead-letter behaviour. Find out what happens to a message the relay repeatedly fails to publish: retried forever, skipped, or parked? This isn't in the frozen list, so it may be config rather than code, and it determines whether a poison message stalls the whole relay. Test it deliberately by publishing an unpublishable payload in staging.
  • Debugging checklist, in order: (1) is the row committed and visible in the outbox table? (2) is the relay running and does it hold the lock? (3) do the relay's broker settings point at the intended connection (config cache stale after deploy is a frequent culprit — php artisan config:clear)? (4) do the metrics show lag climbing or flat? That sequence separates producer bugs from relay bugs in under a minute.
  • File issues rather than shimming. With 1.0 shipped, ergonomics changes are no longer "free before the freeze" — but non-frozen surface can still improve in a minor, and a maintainer with a proven six-day, 775k-order demo run has demonstrated they take correctness seriously. A report is cheaper than a permanent wrapper around the package.
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.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
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
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core