DEV Community

Cover image for The Outbox Pattern in PHP from Scratch (No Library, 80 Lines)
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Outbox Pattern in PHP from Scratch (No Library, 80 Lines)


You ship an order service. The use case does two things: insert a row into orders, then publish an OrderPlaced event to RabbitMQ so the email worker, the warehouse worker, and the analytics pipeline can do their jobs.

A week later a customer support ticket lands. Order o_91f2 is in the database. The customer never got an email. Warehouse never saw it. Analytics counted it. Three downstream systems, three different answers about the same order.

You look at the code:

$this->orders->save($order);
$this->bus->publish(new OrderPlaced($order->id));
Enter fullscreen mode Exit fullscreen mode

Between line one and line two, the broker connection blipped, or the PHP-FPM worker got OOM-killed, or the deploy rolled the pod mid-request. The database commit landed. The publish did not. There is no retry, no record that the publish was meant to happen, no way to reconcile.

This is the dual-write problem. It is everywhere in event-driven PHP services. The outbox pattern fixes it. You do not need a library. You need one table, one INSERT inside the same transaction, and a worker that drains the table. Total code below: about 80 lines of PHP 8.3 plus a small SQL migration.

The outbox pattern: domain write and event write commit together, a worker drains the table to the broker

Why the obvious fix does not work

Before the outbox, two patterns get tried. Both fail.

Pattern 1: publish then save. Publish the event first, then write to the database. If the DB write fails, you have already told the world an order exists that does not. Reads from the consumer side will look up the order and find nothing. Downstream code grows defensive nulls. This is worse than the original bug.

Pattern 2: save then publish, with a try/catch. Wrap the publish in a try/catch and log on failure. The log line is comforting. Nothing replays the failure. Next deploy, someone greps the logs for publish failed, sees 11 of them, and now has to write a one-off script to reconstruct what should have been published. The script is wrong because the event payload has drifted since then.

The root cause is that two systems (your database and your broker) cannot share a transaction. There is no atomic commit across them. You can fake one with two-phase commit, but XA on PHP-to-RabbitMQ is a route nobody walks for fun.

The outbox sidesteps the whole problem. You only write to the database. The event is a row in a table next to your domain rows, committed in the same transaction. A separate process reads the table and publishes. If the publish fails, the row stays. The worker tries again.

The table

One migration. PostgreSQL 14+ syntax. SQLite and MySQL 8 work with minor changes.

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;
Enter fullscreen mode Exit fullscreen mode

Six columns. dispatched_at is nullable; NULL means the event still needs to go out. The partial index keeps the scan cheap once the table grows. The worker only ever queries rows where dispatched_at IS NULL, and that set stays small as long as the worker keeps up.

payload is JSONB. The shape is whatever your domain event looks like serialized: a flat dict of primitives. Resist the urge to store the full domain object; future-you will rename fields and the old rows will not match. Treat the row as an immutable record of what was true at write time.

Writing the event inside the use case transaction

The whole point of the pattern is that the event row and the domain row commit together. That means the outbox write lives inside the same transaction as the domain write — typically inside a use case (Clean Architecture) or application service (Hexagonal).

Here is a PlaceOrder use case in PHP 8.3 with PDO:

<?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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Two writes, one transaction. If either fails, both roll back. Either both rows commit or neither does.

The OutboxWriter itself is twelve lines:

<?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),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

UUIDv7 is monotonically time-ordered, which matters later for ordered dispatch. If you are on PHP without ramsey/uuid, swap in any v7 generator or fall back to v4 plus the existing occurred_at ordering.

That is the entire write path. The use case calls into a port (OutboxWriter), the adapter is one INSERT, the rest is PHP's normal PDO transaction handling.

The worker

The worker is a long-running CLI script. It loops: pull a batch of pending events, publish each one, mark dispatched, sleep briefly, repeat.

The trick that makes it safe under concurrent workers is SELECT ... FOR UPDATE SKIP LOCKED. Postgres locks the rows the worker picked up, and any second worker that runs the same query skips them and grabs different rows. You can run three workers in parallel and they will never deliver the same event twice.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Outbox;

final class OutboxDispatcher
{
    public function __construct(
        private \PDO $db,
        private EventPublisher $publisher,
        private int $batchSize = 50,
    ) {}

    public function tick(): int
    {
        $this->db->beginTransaction();

        $stmt = $this->db->prepare(
            'SELECT id, aggregate_id, event_type, payload
               FROM outbox_events
              WHERE dispatched_at IS NULL
              ORDER BY occurred_at
              LIMIT :n
              FOR UPDATE SKIP LOCKED'
        );
        $stmt->bindValue(':n', $this->batchSize, \PDO::PARAM_INT);
        $stmt->execute();
        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);

        if ($rows === []) {
            $this->db->commit();
            return 0;
        }

        $mark = $this->db->prepare(
            'UPDATE outbox_events
                SET dispatched_at = now()
              WHERE id = :id'
        );

        foreach ($rows as $row) {
            $this->publisher->publish(
                eventType: $row['event_type'],
                aggregateId: $row['aggregate_id'],
                payload: json_decode(
                    $row['payload'], true, flags: JSON_THROW_ON_ERROR
                ),
            );
            $mark->execute([':id' => $row['id']]);
        }

        $this->db->commit();
        return count($rows);
    }
}
Enter fullscreen mode Exit fullscreen mode

A CLI entrypoint runs tick() in a loop:

$dispatcher = $container->get(OutboxDispatcher::class);

while (true) {
    $n = $dispatcher->tick();
    if ($n === 0) {
        usleep(200_000);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it as a systemd service, a Kubernetes deployment, a Supervisor program — whatever your stack uses for long-lived PHP processes. Three workers on three pods, no coordination, all picking up different rows because of SKIP LOCKED.

Worker loop: SELECT FOR UPDATE SKIP LOCKED, publish, mark dispatched, repeat

Delivery semantics

The pattern gives you at-least-once delivery. The publisher can crash after publishing but before marking dispatched. On restart, the worker will pick the row up again and republish. Consumers must be idempotent — keep a processed_event_ids table or check by event UUID before acting.

If you need ordering per aggregate (order.placed before order.paid for the same order), the ORDER BY occurred_at already gives it to you within a single worker. Across workers, you have two choices: shard by aggregate_id (hash modulo worker count) so one aggregate's events always land on one worker, or accept that downstream consumers re-order using the event timestamp. The shard route is more work; the timestamp route is usually fine because consumers grouped by aggregate also tend to be the ones holding state per aggregate.

If you really need exactly-once, the outbox alone will not give it to you. Exactly-once is a downstream-consumer concern, solved by the consumer's idempotency key. The outbox publishes at least once. The consumer must dedupe. Combined, you get effectively-once delivery.

Operational notes after the table grows

Two things will bite you in month three.

Table bloat. outbox_events grows forever if nothing prunes it. Add a daily job:

DELETE FROM outbox_events
 WHERE dispatched_at IS NOT NULL
   AND dispatched_at < now() - INTERVAL '7 days';
Enter fullscreen mode Exit fullscreen mode

Seven days is generous; pick whatever your audit needs require. The partial index on dispatched_at IS NULL already keeps the worker query fast, so the bloat only hurts disk and full-table scans. Pruning fixes both.

Stuck rows. If the publisher repeatedly fails on one event (malformed payload, broker-side schema rejection), the row sits there and the worker keeps retrying it forever, blocking newer events for the same aggregate. Add a retry counter and a dead-letter outcome:

ALTER TABLE outbox_events
    ADD COLUMN attempts INT NOT NULL DEFAULT 0,
    ADD COLUMN last_error TEXT;
Enter fullscreen mode Exit fullscreen mode

In the worker, wrap the publisher->publish call in a try/catch: on success, run the existing UPDATE ... SET dispatched_at = now(); on failure, run UPDATE outbox_events SET attempts = attempts + 1, last_error = :msg WHERE id = :id and continue. Then change the SELECT to add AND attempts < 10 so poisoned rows fall out of the pending set automatically (point a dead-letter view at attempts >= 10 for on-call). Without it, one bad row will page you at 3am.

What you have built

Around 80 lines of PHP plus a small SQL migration, and you have:

  • Atomic write of the domain row and its event.
  • Concurrent workers, no coordination, courtesy of FOR UPDATE SKIP LOCKED.
  • At-least-once delivery, retry-safe.
  • A real durable record of every event the service has emitted.

No Symfony Messenger transport. No Laravel queue driver. No Kafka Connect Debezium pipeline. Those tools earn their weight once you outgrow this. For the first year of a service, this is what they all reduce to underneath.

The hard part isn't the broker or the framework. It's the boundary between your domain and the broker, and the question of what commits with what. The outbox draws that boundary with one table.


If this was useful

This is one chapter of how Decoupled PHP treats async adapters — events get committed alongside the domain row, the worker is an outbound adapter behind a port, and the framework (Laravel or Symfony) shows up only at the edges. If you want to see the full layout — the port for EventPublisher, the dead-letter path, the testing strategy for the dispatcher without a real broker — the book walks the whole shape end to end.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)