Razvoj

PHP Transactions: The Outbox Pattern for Reliable Inter-Service Communication

PHP transakcije: obrazac Outbox za pouzdanu komunikaciju među servisima

Most inter-service failures are not dramatic outages. They are quiet gaps: an order is saved, but the billing event is never sent; an email is queued twice after a retry; a worker crashes between publishing a message and recording that it did so.

The root problem is simple. A database transaction and a message broker are separate systems. If application code writes to one and then the other, there is always a failure window between those operations. The outbox pattern closes that window by making the business change and the intent to publish an event part of the same database transaction.

Why “save, then publish” is unreliable

A typical service starts with code that looks reasonable: create an order, commit it, then publish OrderCreated. It works until the process loses its connection, is terminated, or the broker is temporarily unavailable after the database commit.

Reversing the sequence does not solve the problem. Publishing first can expose an event for an order that later fails to commit. Retrying either sequence introduces duplicates unless every consumer is prepared for them.

The outbox pattern changes the contract. Instead of publishing directly inside the request, the service writes an event record into its own database alongside the business data. A separate relay process publishes pending records later.

The transactional outbox in practice

An outbox table is ordinary application data. Keep enough information to identify the event, route it, reproduce its payload, and track delivery progress. The exact schema depends on the database and broker, but the essentials are stable.

CREATE TABLE outbox_messages (
    id CHAR(36) PRIMARY KEY,
    event_type VARCHAR(100) NOT NULL,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id CHAR(36) NOT NULL,
    payload JSON NOT NULL,
    occurred_at TIMESTAMP NOT NULL,
    published_at TIMESTAMP NULL,
    attempts INT NOT NULL DEFAULT 0
);

When an order is created, insert both the order and its event before committing. The important detail is not the JSON shape; it is that both inserts use the same database connection and transaction.

<?php

$pdo->beginTransaction();

try {
    $orderId = $uuidFactory->create();

    $insertOrder = $pdo->prepare(
        'INSERT INTO orders (id, customer_id, total_amount)
         VALUES (:id, :customer_id, :total_amount)'
    );
    $insertOrder->execute([
        'id' => $orderId,
        'customer_id' => $customerId,
        'total_amount' => $totalAmount,
    ]);

    $messageId = $uuidFactory->create();
    $payload = json_encode([
        'event_id' => $messageId,
        'order_id' => $orderId,
        'customer_id' => $customerId,
        'total_amount' => $totalAmount,
    ], JSON_THROW_ON_ERROR);

    $insertOutbox = $pdo->prepare(
        'INSERT INTO outbox_messages
         (id, event_type, aggregate_type, aggregate_id, payload, occurred_at)
         VALUES (:id, :event_type, :aggregate_type, :aggregate_id, :payload, CURRENT_TIMESTAMP)'
    );
    $insertOutbox->execute([
        'id' => $messageId,
        'event_type' => 'OrderCreated',
        'aggregate_type' => 'order',
        'aggregate_id' => $orderId,
        'payload' => $payload,
    ]);

    $pdo->commit();
} catch (Throwable $exception) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $exception;
}

If the transaction commits, the order and its event intent both exist. If it rolls back, neither exists. The request can now return without requiring the broker to be healthy at that exact moment.

Build the relay for at-least-once delivery

A background worker reads unpublished messages, sends them to the broker, and marks successful messages as published. This gives reliable eventual delivery, not exactly-once delivery.

There is an unavoidable edge case: the worker can successfully publish a message and crash before updating published_at. On restart, it publishes the message again. That is correct behavior for an outbox relay. Trying to eliminate this retry with a simple flag risks losing events instead.

Consumers must therefore be idempotent. Include a stable event ID in every payload and retain enough state to recognize events already processed. A payment service, for example, should not create a second charge merely because it receives the same OrderCreated event twice.

Claim work before publishing

Multiple relay workers need coordination. Do not let every worker select the same unpublished rows and publish them simultaneously. Use a claiming strategy supported by the chosen database, such as row locking during selection or a lease column updated in a short transaction.

A lease-based design typically stores a claim token and expiry time. A worker claims a bounded batch, publishes each item, then marks each successful item as published. If the worker dies, expired leases make messages available again. Keep claims short and batches modest so a slow broker does not hold database locks for the duration of network I/O.

Record attempt counts and the latest error where operationally useful. After repeated failures, route the message to a reviewable failure state rather than retrying forever without visibility. The business decision matters here: some events can be repaired manually, while others require an automated compensating workflow.

Ordering, payloads, and schema evolution

The outbox gives durability, but it does not automatically guarantee global ordering. In distributed systems, global ordering is usually more expensive than it is valuable. Ask what actually needs ordering.

For many domains, ordering only matters per aggregate: events for one order, account, or shipment. Include the aggregate identifier in the message and configure downstream handling around that boundary where the broker supports it. Consumers should also tolerate delayed delivery and duplicate delivery.

Make event payloads self-contained enough for consumers to act without synchronously calling back into the producer. A minimal event that contains only an ID often recreates coupling through follow-up API calls. At the same time, avoid treating an event as a full database replica. Publish the facts consumers need, version the event shape deliberately, and preserve backward compatibility while old consumers exist.

  • Use explicit event names: OrderCreated communicates a completed fact, not an internal command.
  • Include an event ID: it is the foundation for consumer-side deduplication.
  • Keep a clear retention policy: published rows may be archived or deleted only after the operational and audit requirements are understood.
  • Monitor backlog age: a growing number of pending rows is useful, but the age of the oldest pending event often reveals customer impact sooner.

Keep the first version boring

The outbox pattern does not require an elaborate event platform on day one. A table, a transaction, a small relay, and idempotent consumers are already a substantial reliability improvement. Start with clear ownership: each service writes its own outbox and publishes events about its own committed state.

That modest design changes how failures behave. Instead of turning a brief broker interruption into missing business actions, the system stores its promise to communicate and keeps trying. In backend engineering, that is often the difference between a system that merely works in the happy path and one that remains trustworthy when the happy path disappears.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.