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.
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:
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.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.
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.
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.^1.0 and roll the image; check UPGRADE.md at each future major, not each minor.SIGKILL, node loss, not a rolling restart) is the classic failure.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.How can I help you explore Laravel packages today?