iToverDose/Software· 19 MAY 2026 · 20:09

Solve Dual-Write Problems in PHP with the Outbox Pattern

Avoid lost events and inconsistent state by using a single transaction for both database writes and message publishing. Learn how the outbox pattern ensures reliability in event-driven PHP applications.

DEV Community4 min read0 Comments

Event-driven PHP applications often face a critical challenge: ensuring that database writes and message publishing happen reliably together. When these two operations occur separately, a failure after the database commit but before the message is sent can leave downstream systems in the dark—customer emails never arrive, warehouse systems miss updates, and analytics pipelines record incomplete data. The outbox pattern solves this by consolidating both operations into a single transaction, guaranteeing consistency without relying on external libraries.

Why Dual-Writes Create Chaos in Event-Driven Systems

Many PHP services handle orders by first saving the order to a database and then publishing an event to a message broker like RabbitMQ. Developers often assume this sequence is atomic, but in reality, the two actions operate in separate systems. If the publishing step fails—due to a network blip, a crashed worker, or a pod restart—the database records the order, but downstream services never receive the event. The result? Inconsistent state across the application.

Common "fixes" for this issue often introduce new problems:

  • Publish then save: Events are sent before the database commit. If the database write fails, the event refers to a non-existent order, forcing consumers to handle null checks and leading to defensive coding patterns.
  • Save then publish with error logging: Developers wrap the publishing step in a try-catch block, logging failures for later review. However, these logs don’t automatically trigger retries. A developer might later grep through logs, identify missing events, and manually reconstruct payloads—only to realize the event data has since changed, making the recovery process unreliable.

The fundamental issue remains: databases and message brokers cannot share a single transaction. Two-phase commit protocols exist, but they’re rarely implemented in PHP-to-RabbitMQ setups due to complexity and performance overhead.

The Outbox Pattern: A Reliable Alternative

The outbox pattern simplifies this problem by treating events as first-class database records. Instead of publishing directly to a broker, the application writes the event to a dedicated table—outbox_events—within the same transaction as the domain write. A separate worker process then reads these pending events and publishes them to the broker. If the publishing step fails, the event remains in the table, unmarked as dispatched, so the worker can retry later.

This approach guarantees consistency: either both the domain data and the event are committed, or neither is. It eliminates the risk of partial updates and removes the need for manual recovery scripts.

Designing the Outbox Table for Scalability

The outbox_events table requires minimal structure but must support efficient querying and dispatching. Here’s a PostgreSQL 14+ schema that balances simplicity with performance:

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_id TEXT NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    dispatched_at TIMESTAMPTZ
);

CREATE INDEX outbox_events_pending_idx
    ON outbox_events (occurred_at)
    WHERE dispatched_at IS NULL;

Key design choices:

  • UUIDv7 for event IDs: These IDs are time-ordered, ensuring events are processed in the correct sequence. If your PHP environment lacks the ramsey/uuid library, alternatives like symfony/uid or custom UUIDv7 generators work as substitutes.
  • Immutable payloads: Store only the event data at the time of creation. Avoid serializing entire domain objects, as field renames or structural changes could break historical records. Treat each row as a snapshot of what was true when the event occurred.
  • Partial index for pending events: The index outbox_events_pending_idx filters rows where dispatched_at IS NULL, keeping the active dataset small and queries fast even as the table grows.

Implementing the Outbox Writer in PHP 8.3

The core of the outbox pattern is a lightweight writer class that inserts events into the outbox_events table within the same transaction as domain writes. Below is a complete implementation for a PlaceOrder use case:

<?php

declare(strict_types=1);

namespace App\Application\PlaceOrder;

use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Application\Outbox\OutboxWriter;

final readonly class PlaceOrder
{
    public function __construct(
        private \PDO $db,
        private OrderRepository $orders,
        private OutboxWriter $outbox,
    ) {}

    public function handle(PlaceOrderCommand $cmd): string
    {
        $order = Order::place(
            customerId: $cmd->customerId,
            items: $cmd->items,
        );

        $this->db->beginTransaction();
        try {
            $this->orders->save($order);
            $this->outbox->write(
                aggregateId: $order->id,
                eventType: 'order.placed',
                payload: [
                    'order_id' => $order->id,
                    'customer_id' => $order->customerId,
                    'total_cents' => $order->totalCents,
                ],
            );
            $this->db->commit();
        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }

        return $order->id;
    }
}

The OutboxWriter class handles the low-level insertion of events:

<?php

declare(strict_types=1);

namespace App\Application\Outbox;

use Ramsey\Uuid\Uuid;

final readonly class OutboxWriter
{
    public function __construct(private \PDO $db) {}

    public function write(
        string $aggregateId,
        string $eventType,
        array $payload,
    ): void {
        $stmt = $this->db->prepare(
            'INSERT INTO outbox_events (id, aggregate_id, event_type, payload)
             VALUES (:id, :agg, :type, :payload)'
        );

        $stmt->execute([
            ':id' => Uuid::uuid7()->toString(),
            ':agg' => $aggregateId,
            ':type' => $eventType,
            ':payload' => json_encode($payload, JSON_THROW_ON_ERROR),
        ]);
    }
}

This writer ensures that events are stored reliably alongside domain data, with no risk of partial commits.

The Worker: Publishing Events Reliably

A separate worker process polls the outbox_events table for undispatched events and publishes them to RabbitMQ. If the worker crashes mid-publish, the transaction remains open, and the event stays in the table. Upon restart, the worker resumes from the last successfully dispatched event, ensuring no messages are lost.

This decoupling of writes and publishing provides resilience. The application no longer depends on the immediate availability of the broker—only on the integrity of its own database transaction.

A Future-Proof Foundation for Event-Driven PHP

The outbox pattern is not just a workaround for dual-write problems—it’s a best practice for building reliable event-driven systems in PHP. By treating events as persistent records, you eliminate race conditions, simplify recovery, and reduce operational complexity. The implementation is minimal—just 80 lines of PHP 8.3 and a straightforward database migration—but the benefits are substantial. Whether you’re building order management systems, real-time analytics pipelines, or microservices, the outbox pattern ensures your events are as reliable as your data.

AI summary

Learn how to fix dual-write problems in PHP applications with the outbox pattern. Store events in the database and publish them reliably without external libraries.

Comments

00
LEAVE A COMMENT
ID #POUQWV

0 / 1200 CHARACTERS

Human check

2 + 7 = ?

Will appear after editor review

Moderation · Spam protection active

No approved comments yet. Be first.