Back to Blog
Lesson 21 of the Advanced Laravel: Architecture, Scaling & Performance course
LaravelJune 28, 20264 min read

Eventual Consistency Patterns: Implementing Outbox and Reconciliation

Master eventual consistency in distributed systems by implementing the Outbox pattern and robust reconciliation tasks to ensure reliable state across services.

LaravelDistributed SystemsArchitectureConsistencyOutboxphpbackend

Previously in this course, we explored Distributed Transactions and Sagas: Managing Consistency in Laravel to handle multi-service workflows. While Sagas are excellent for coordinating long-running business processes, they often rely on the underlying assumption that events are delivered reliably. This lesson adds the "ground truth" layer: ensuring that local state changes and their associated events are atomic, and building the reconciliation mechanisms to handle the inevitable drift that occurs in distributed environments.

The Problem: The Dual-Write Dilemma

In a distributed architecture, you frequently need to update your database and trigger an external action—like publishing a message to RabbitMQ or calling a third-party API—simultaneously.

If you update the DB and then dispatch the event, the server might crash before the event fires. If you fire the event then update the DB, the database transaction might fail, leaving the external service with "ghost" data. This is the classic dual-write problem. We solve this by treating consistency as an eventual state, using the Outbox pattern to bridge the gap.

Implementing the Transactional Outbox Pattern

The Outbox pattern ensures that your database state change and the event intent are stored in the same atomic transaction. Instead of firing an event directly, we write a record to an outbox table within the same transaction that updates our business entity.

1. The Migration

PHP
Schema::create('outbox', function (Blueprint $table) {
    $table->id();
    $table->string('event_type');
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
});

2. Atomic Dispatch

We wrap our business logic and the outbox insertion in a single database transaction. If the transaction rolls back, the event is never saved, and consequently, never sent.

PHP
public function createOrder(array $data)
{
    return DB::transaction(function () use ($data) {
        $order = Order::create($data);
        
        #6A9955">// Instead of Event::dispatch(), we write to the outbox
        DB::table('outbox')->insert([
            'event_type' => OrderCreated::class,
            'payload' => json_encode($order->toArray()),
            'created_at' => now(),
        ]);

        return $order;
    });
}

3. The Relay Process

A background worker (or a scheduled task) polls the outbox table, dispatches the events, and marks them as processed. This decouples the transaction from the delivery.

Handling Reconciliation Tasks

Even with an Outbox, networks fail, workers crash, and systems drift. Reconciliation is the process of periodically verifying that the state of your "source of truth" matches the state of your downstream systems.

Reconciliation usually follows a three-step cycle:

  1. Snapshotting: Identify a set of records updated within a time window.
  2. Comparison: Query the downstream service (or check its logs) to verify the expected state.
  3. Correction: Re-trigger events or issue direct API calls to fix discrepancies.

Example: Reconciliation Job

PHP
class ReconcileOrdersJob implements ShouldQueue
{
    public function handle()
    {
        #6A9955">// Find orders created in the last 10 minutes that haven't been synced
        Order::where('created_at', '>=', now()->subMinutes(10))
            ->where('synced_to_warehouse', false)
            ->chunk(100, function ($orders) {
                foreach ($orders as $order) {
                    #6A9955">// Logic to re-sync or verify with external API
                    WarehouseService::sync($order);
                    $order->update(['synced_to_warehouse' => true]);
                }
            });
    }
}

Comparison: Strategies for Consistency

PatternMechanismProsCons
Transactional OutboxDB table + RelayAtomic, no data lossRequires polling/CDC
Saga (Orchestration)Coordinator ServiceClear workflow controlHigh complexity
ReconciliationPeriodic AuditSelf-healing, robustHigh latency, I/O intensive

Hands-on Exercise

  1. Create an outbox table in your current SaaS project.
  2. Refactor one of your existing Action Classes to write to the outbox instead of firing a standard Laravel Event.
  3. Implement a RelayOutboxJob that processes pending events. Ensure it marks records as processed_at to prevent duplicate processing.
  4. Add a ReconciliationTask that runs hourly to check for any orders created in the last 24 hours that lack a corresponding event in the log.

Common Pitfalls

  • Duplicate Delivery: If your relay process crashes after sending the event but before updating the processed_at flag, you will send the event again. Always design your event consumers to be idempotent.
  • Table Bloat: The outbox table will grow indefinitely. Implement a cleanup task to prune processed records older than 7 days.
  • Polling Frequency: Don't set your relay to poll every second. Use a reasonable interval (e.g., every 15–30 seconds) or leverage database triggers/Change Data Capture (CDC) if your scale requires sub-second latency.

Recap

Consistency in distributed systems is rarely achieved through a single magic bullet. By using the Outbox pattern, we guarantee that events are eventually sent. By implementing reconciliation tasks, we create a self-healing system that recovers from edge-case failures. These patterns are essential for any high-traffic SaaS where data integrity is non-negotiable.

Up next: We will dive into Multi-Layered Caching Strategy, where we manage complex state invalidation across distributed cache layers.

Similar Posts