Your order service calls DB::transaction(), the row lands in the orders table, and a few lines later a job goes out to send a confirmation email. Most of the time, nobody notices that these are two separate operations talking to two separate systems. Then one day the transaction commits and the job never fires. Or the job fires and finds nothing in the database yet. Nobody changed the code. The timing just went wrong.
This is the dual-write problem, and it shows up in almost every PHP application that pairs a relational database with a message queue.
Why Database Transactions and Queues Fall Out of Sync
A database transaction and a message broker are two independent systems, each with its own commit point. Unless you’re running real two-phase commit across both (rare, and usually not worth the operational cost), there’s no way to guarantee that both succeed or fail together. Chris Richardson, who documented this problem on his microservices.io pattern site, puts the requirement plainly: a service command that updates the database and sends a message has to do both atomically. Skip that guarantee and you get data inconsistencies and bugs downstream.
That gap creates two separate failure windows.
The first happens when a job is dispatched before the surrounding transaction commits. Laravel’s maintainers walked through this exact bug in the framework’s repository years ago, and their explanation still holds. A worker can pick up and run a queued job before the transaction that created its data has committed. From the worker’s point of view the record doesn’t exist yet, so the job throws errors.
The second window is the reverse, and it’s harder to fix. The database commits cleanly, then the application crashes or the broker call fails before the message is safely accepted. There’s nothing left to retry, because the intent to publish only ever existed in application memory.
Is Laravel’s afterCommit() Enough?
For a lot of PHP applications, mostly yes. Laravel’s queue configuration has an after_commit option (off by default) that holds job dispatch until the surrounding database transaction has committed. The framework’s own package documentation spells out the behavior: if the transaction rolls back, the queued job is thrown away instead of dispatched, and if there’s no open transaction, the job goes out right away.
That closes the first failure window. It doesn’t cover the moment after the commit but before the dispatch reaches the broker. If the process crashes in that small gap, or the broker call fails, the event is gone, and nothing records that it was ever supposed to be sent. For a marketing email, who cares. For a payment confirmation or an inventory adjustment, it’s an inconsistency someone will eventually have to fix by hand.
How the Transactional Outbox Works in PHP
The transactional outbox closes the second window. You write the business row and a record of the event you plan to publish in the same database transaction, on the same connection. Richardson’s write-up lists the three moving parts: a database that stores both the business data and the outbox, the outbox table itself with messages waiting to go out, and a separate message relay that reads those rows and forwards them to the broker.
DB::transaction(function () use ($orderData) {
$orderId = DB::table('orders')
->insertGetId($orderData);
DB::table('outbox_events')->insert([
'id' => (string) Str::uuid(),
'event_type' => 'order.created',
'aggregate_id' => (string) $orderId,
'payload' => json_encode(
['order_id' => $orderId],
JSON_THROW_ON_ERROR
),
'created_at' => now(),
]);
});
Nothing has been published yet. What you have is a durable record that you meant to publish, and that record can’t exist without the business row behind it, because both share the same transaction.
Building a Reliable Outbox Relay
The relay is where the real engineering happens. It has to select pending rows, claim them safely when several workers run at once, publish to the broker, and record the result. PostgreSQL’s own documentation describes the tool most relays use here. A SELECT ... FOR UPDATE SKIP LOCKED clause locks a row for update and simply skips any row another transaction already holds, so several relay workers can pull different items from the same table without colliding. It’s been around since PostgreSQL 9.5, so it isn’t new, but it’s the standard way to turn an ordinary table into a safe concurrent work queue.
What a relay can’t always tell is whether a publish actually landed. A broker timeout might mean the message never arrived. It might also mean it arrived and only the acknowledgment got lost. Amazon is upfront about this in its own SQS documentation: standard queues guarantee at-least-once delivery, but a message can occasionally be delivered more than once because of retries or network delays. Applications are expected to handle that with idempotent operations instead of assuming duplicates won’t happen. A relay that retries after an unclear outcome will publish the same event twice sooner or later. You can’t engineer that away. The rest of the pipeline just has to cope with it.
Retries, Duplicates, and Idempotent Consumers
Since delivery is at least once, not exactly once, consumers need their own protection. Record each processed event’s ID in the same transaction as the local change it triggers, and check that ID before acting on any new delivery.
It’s worth being precise about what PHP frameworks actually ship here, because “inbox pattern” gets thrown around loosely. Symfony’s Messenger component introduced deduplication middleware in version 7.3, which uses locks to prevent duplicate messages from being dispatched or processed. Symfony 8.1 refined this behavior by releasing the lock immediately when a message fails definitively and is moved to the failure transport, rather than holding it until the lock’s TTL expires. That’s genuinely useful, but Symfony 8.2 takes this further with dedicated transactional outbox and transactional inbox transport options. The outbox ensures messages are stored in the same database transaction as business changes before being forwarded to the broker, while the inbox records successfully processed message IDs transactionally to prevent duplicate handling. Symfony 8.2 is scheduled for release in November 2026. For earlier Symfony versions, teams can still implement consumer-side deduplication using their own processed-events table or a community bundle.
None of this helps with external API calls. If your consumer charges a card or notifies a third-party service, you need that provider’s idempotency key support or a reconciliation job. Local deduplication can’t make an external side effect atomic with your database write.
Polling vs. Change Data Capture
Relays pick up events in one of two ways. Polling queries the outbox table on a schedule. It’s simple to build and easy to reason about, but it adds read load and a small, tunable delay. Change Data Capture (CDC) reads committed changes straight from the database’s replication log. Debezium’s Outbox Event Router is the standard tool for this. It’s a message transformation that a Debezium connector applies to route outbox rows into the right topics, based on the table’s structure and an aggregate identifier. Debezium notes that it doesn’t work with the MongoDB connector, which has its own router for that case.
Neither is better across the board. Polling is the sensible default for moderate volume and small teams. CDC is worth the extra infrastructure (a connector, its own monitoring, and a team that can run it) only when throughput or latency really demands it.
Testing, Monitoring, and Recovery in Production
A passing happy-path test proves very little. Test these on purpose: a transaction rollback, a crash right after commit, a broker timeout with an unclear outcome, a relay crash both before and after a successful publish, a duplicate delivery reaching the consumer, and payload compatibility after a deployment changes the event schema. In production, the numbers that actually tell you something’s wrong are the number of pending events, the age of the oldest unpublished one, exhausted retries, and consumer lag. If nobody watches those, a stuck relay can sit there for hours before anyone notices that orders have stopped reaching fulfillment.
The Failure Matrix
Scenario
What should happen
Worker starts before commit
Dispatch waits until the transaction closes
Business transaction rolls back
Business row and outbox row roll back together
Crash right after commit
Outbox row survives and waits for the relay
Relay crashes mid-claim
The claim expires so another worker can pick it up
Broker timeout, outcome unclear
Retry, and accept that a duplicate may result
Consumer commits, acknowledgment lost
Consumer-side deduplication prevents repeat effects
An event keeps failing
Bounded retries, an alert, and a controlled replay path
When Is an Outbox Worth the Complexity?
Most PHP applications don’t need one. The ones that do usually handle money, inventory, or anything else where a silently dropped event turns into a support ticket.
An occasional missed event is tolerable and recoverable through support: after-commit dispatch is enough.
The queue backend can share the business transaction directly: a transactional database queue covers it.
Publishing intent has to survive a crash, and the broker is external: this is where an outbox earns its keep.
Volume and latency requirements are real, and the team can operate extra infrastructure: CDC becomes worth considering.
Conclusion
After-commit dispatch fixes a timing problem. The outbox fixes a durability problem: publish intent survives anything short of losing the database itself. Neither one gives you exactly-once delivery, and an honestly built outbox shouldn’t claim to. What you get instead is a system where failures are expected, visible, and recoverable. That’s a more realistic goal than chasing a guarantee no independent broker can give you, and for most teams, it’s a more achievable one.
FAQ
Is Laravel’s afterCommit() enough on its own? Often, yes, for workflows that can live with an occasional missed event and recover through support or reconciliation.
Does an outbox guarantee exactly-once delivery? No. It guarantees durable publish intent with at-least-once delivery, so you still need idempotent consumers.
How do I stop duplicate queue messages from causing problems? You can’t prevent duplicates entirely. Deduplicate on the consumer side using stable event IDs, and use provider idempotency keys for external calls.
Polling or CDC? Start with polling unless your latency or throughput needs are already beyond what periodic queries can handle. Add CDC only once the team can run the connector.
If your PHP application already relies on queues, jobs, and async workflows for orders, payments, or notifications, the decisions covered here (after-commit dispatch, outbox tables, and relay design) aren’t academic. They decide whether a crash at 2 a.m. costs you a stuck job or a customer who never got their confirmation. TechoSquare works with growing businesses on exactly this kind of PHP backend engineering, from building new applications on solid transactional foundations to reviewing and hardening systems that already handle real transaction volume. If you’re trying to decide whether your setup needs an outbox or just a cleaner transaction boundary, it’s worth a conversation before the next incident forces the question.
About Vineet Arora
Vineet Arora writes about web development, software engineering, and modern application technologies for TechoSquare, with a focus on website architecture, backend development, API integrations, database management, and application performance. His work aims to make technical topics like the one above accessible to developers, business owners, and technology decision-makers alike, breaking down the engineering trade-offs behind reliable, scalable PHP systems so readers can make informed decisions about building, maintaining, and growing their digital products.

Top comments (0)