Backend Architecture

Write the Row and the Event in One PostgreSQL Transaction

If PHP commits business state and then publishes to a queue, those two writes will diverge. An outbox table in the same transaction is the fix, and it stays valid when a consumer moves to Go.

PHP finishes the order. The transaction commits. The next line publishes a message so the warehouse, the invoice worker, or a new Go service can react. The process dies between those two lines, or Redis is briefly down, or the publish throws and the catch block logs it. The order exists. The rest of the system never hears about it. Support finds out from a customer.

The inverse failure also happens. You publish first, then commit, and the transaction rolls back. A worker ships a product for an order that does not exist. You add a retry. Now you sometimes ship twice.

This is the dual-write problem. It shows up in every PHP application that treats “the database” and “the queue” as two local calls that usually both work. It gets worse when you introduce a Go consumer beside the PHP one, because you now have two readers and still no guarantee the message was written with the row.

A transactional outbox puts the event in PostgreSQL, in the same transaction as the business write. A separate relay reads the outbox and publishes. If the business transaction rolls back, the event rolls back with it. If the process dies after commit, the event is still in the table, waiting.

The table

Keep it boring. One table is enough until volume forces a split.

Columns that earn their place:

  • id, a UUID or a bigserial. Consumers use it as an idempotency key.
  • aggregate_type and aggregate_id, so a human can find the order this event belongs to.
  • type, a stable name (order.placed), not a PHP class name. A class name is a private detail. A Go consumer should not import it.
  • payload, JSON. The facts the consumer needs, not a dump of the entity graph. If the consumer must read current state, give it an id and let it read through an API or a table it is allowed to read. Do not serialize an Eloquent model into the payload.
  • occurred_at, set in the application from an injected clock.
  • published_at, null until the relay has handed the event to the broker, or until you decide the relay’s own mark is enough.
  • Optional attempts and last_error if you want poison events visible without a second log system.

Write the row with the business change:

BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload, occurred_at)
VALUES (...);
COMMIT;

In PHP that is one DB::transaction, one Doctrine transaction, or one explicit BEGIN around both inserts. If any code path updates the order and “fires an event” through a static call that publishes immediately, that path is still a dual write. The outbox insert has to be inside the same unit of work. Framework events that run after commit are too late. Listeners that run before commit and talk to Redis are too early.

Do not let the payload grow into a second source of truth. When the row and the JSON disagree, the consumer will pick one and you will not know which. Put identifiers and the decision (“placed”, “the amount was 1800 cents”) in the payload. Put mutable current state in the table that owns it.

The relay

The relay is a worker. It reads unpublished outbox rows, publishes them, and marks them published.

SELECT … FROM outbox WHERE published_at IS NULL ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100 is the usual shape in PostgreSQL. SKIP LOCKED lets two relay instances run without taking the same rows. You need it once you run more than one. Run more than one only after the single relay is boring.

Mark published in a way that matches the broker’s guarantee. If the publish can succeed and the process can die before published_at is set, the event will be published twice. That is acceptable if every consumer is idempotent on id. It is not acceptable if you “fix” duplicates by publishing at-most-once and then dropping events when the mark fails. At-least-once plus idempotent consumers is the combination you can operate. At-most-once is how orders vanish.

The relay can stay in PHP for a long time. Moving it to Go does not change the table. Moving a consumer to Go does not change the table either. That is the point. The outbox is a contract in PostgreSQL, which both languages already speak. The strangler pattern gets much less magical when the event stream is a table you can query, replay, and back up with the rest of the database.

Poll every second or two if the product can tolerate that latency. Listen on NOTIFY if you cannot. NOTIFY is an optimisation, not the source of truth: a notification can be missed if nothing is listening, so the poll has to exist anyway. Teams that only LISTEN discover the gap during a deploy, when the relay was down for twenty seconds and those events never wake up.

Consumers

A consumer receives the event at least once. It records the outbox id it has applied, in the same transaction as its own side effect, or it uses a unique constraint that makes the second apply a no-op.

“Check a cache, then insert” is not idempotency. Two workers pass the check. You get two invoices. The unique key has to be in the database that commits the side effect.

PHP and Go consumers can run side by side during a cutover if they share that idempotency record, or if only one of them is allowed to perform the side effect while the other shadows. Shadow mode — Go handles the message, writes the result somewhere that does not touch customers, and you diff — is the safe version. Two live writers on the same invoice table is not a cutover. It is a race.

Ordering is per aggregate if you enforce it, and not at all if you do not. The relay should not promise global order. If order ord_1 must see placed before cancelled, the consumer for that aggregate should apply events for ord_1 in occurred_at or id order, and it should not start cancelled on another worker while placed is in flight. A single partition key per aggregate id is the usual broker-level version of the same rule. If you ignore it, cancellation will sometimes beat placement, and the consumer will “handle” a cancel for an order it has not created yet by dropping the event. Then the placement arrives and the order stays open.

Poison messages need a place to sit. After N attempts, set an error column and stop retrying. Alert on the count of unpublished rows older than a few minutes, and on the count of poisoned rows. An outbox nobody looks at is a queue with a worse UI. The useful page is: oldest unpublished, last error, aggregate id, link to the order.

What this does not replace

An outbox is not a service boundary. It does not decide who owns the orders table. If PHP and Go both update that table, the outbox will faithfully emit two streams of conflicting facts. Ownership still has to be one writer, which is the rule in adding a Go service next to PHP.

It is not change-data capture. CDC reads the database log and can feed a warehouse without the application knowing. It is a good tool when you must not touch the write path. It is a worse tool when you want an explicit domain event with a name and a payload a consumer can understand. Triggers that insert into an outbox are a middle ground: the event cannot be forgotten by a code path, and the payload is still easy to get wrong because the trigger sees rows, not intent. Prefer the application insert when you own the code. Use a trigger when there are fifteen write paths and you will not find them all before the next incident.

It is not a reason to put every column change on a bus. A request that can update a row and return the new row to the same user does not need an event. Events are for work that must happen elsewhere, later, exactly once in effect. If the HTTP response is the only consumer, write the row and return it.

It is also not free. You now operate a relay, a table that grows, and a retention job. Delete or archive published rows after a retention window you chose (days or weeks, not forever). An outbox that keeps every payload since 2021 will become the biggest table in the database, and it will hold personal data your deletion process forgets.

When you do not need one

You do not need an outbox when there is no second writer and no second process. A cron command that updates rows and sends mail in one process can still fail between the two, and an outbox helps there too, but a single scheduled command with a “status = pending” column is an outbox. You do not need a second framework to get the effect. The column is the event. The command is the relay.

You do not need a broker in the middle on day one. The relay can be the only consumer, invoking handlers in-process, and you can introduce RabbitMQ or SQS when a second deployable must react. Starting with a broker and without an outbox is the usual order, and it is the order that loses messages. The table is the part that closes the failure window. The broker is the part that fans out.

If the team is about to extract a worker and the publish today sits after commit(), fix the outbox in PHP first. Extracting a consumer that depends on a publish you sometimes skip will produce a Go service that is “flaky” for reasons that are entirely on the PHP side of the commit.

What to look at before you call it done

Take one real flow. Place the order in a transaction you can roll back on purpose. Confirm no event is published. Commit. Kill the relay before it marks the row. Restart it. Confirm the consumer runs once in effect, even though the publish happened twice. Cancel the order and confirm the cancel cannot be applied before the placement on a fresh consumer.

That afternoon tells you more than a diagram of boxes labelled “event bus”. If you want the same pass over the flows that actually move money — which publishes are dual writes, which consumers are idempotent, which table a future Go service would be allowed to append to — that is a Migration Readiness Audit at €1,200. Building the relay and the first consumer is implementation, from €402/day on the PHP to Go migration work when the document says the extraction is real, and it is ordinary PHP work at €402/day when the honest fix is an outbox that stays in the monolith.

The row and the event are one fact. Write them in one transaction. Publish afterwards, and assume the publish will happen more than once.