Laravel Tutorials

Laravel Read Replicas: Check if a Replica Has Caught Up

Published
Laravel Read Replicas: Check if a Replica Has Caught Up image

A Laravel application with a read/write database connection writes to a primary server and reads from one or more replicas. Each replica copies changes from the primary, but it can fall behind. A user who saves a form and lands on the next page can see the old data, because that page read from a replica that has not received the write yet.

The usual fix is a time window. For a few seconds after a write, send that user's reads to the primary. Laravel 13.38 added a PreventStaleDatabaseReads middleware that does this with a two-second default. The two seconds is a fixed estimate of the replication lag. If the window is shorter than the replication lag, the user still reads old data. If it is longer, reads go to the primary when a replica already had the data.

Laravel 13.40 adds three connection methods, contributed by Tobias Petry, that check the replica directly. You record the primary's position after a write, then ask a replica whether it has reached that position.

Record and Check a Commit ID

use Illuminate\Support\Facades\DB;

// After a write, on the primary
$order = Order::create([...]);
$commitId = DB::getLastCommitId();

// Later, on the read connection
DB::hasCommitId($commitId);          // checks once and returns at once
DB::hasCommitId($commitId, wait: 2); // waits up to 2 seconds for the replica

// Combine IDs from several writes into one
$combined = DB::mergeCommitIds([$firstId, $secondId]);

The getLastCommitId() method always queries the write connection. It returns the primary's current replication position as a string. The format depends on the database:

Database Commit ID Example
MySQL GTID set from @@global.gtid_executed 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-77
MariaDB GTID position from @@gtid_binlog_pos 0-1-1052
PostgreSQL WAL position from pg_current_wal_lsn() 0/16B3748

The hasCommitId() method returns true when the server has applied everything up to that commit ID. The wait argument is a whole number of seconds. With wait: 0, the default, the method checks once. With a higher value, it asks the database to block until the replica catches up or the timeout passes, and returns false on timeout.

The mergeCommitIds() method takes an array of commit IDs and returns one ID that includes all of them. On PostgreSQL it returns the highest WAL position. On MySQL and MariaDB it combines the GTID sets, so the result covers writes from more than one primary. It throws a RuntimeException when the array is empty.

Database Requirements

Database Server settings wait argument
MySQL log_bin, gtid_mode = ON, and enforce_gtid_consistency = ON Supported
MariaDB log_bin Supported
PostgreSQL None PostgreSQL 19 and later
SQLite, SQL Server Not supported Not supported

MySQL 8 turns on binary logging by default, but not GTIDs. MariaDB turns off binary logging by default. When a setting is missing, getLastCommitId() throws a RuntimeException that names the missing settings. SQLite and SQL Server connections throw one for every call. On PostgreSQL 18 and earlier, hasCommitId() with a wait value throws, even on the primary. A managed database service might not let you change these settings.

Which Server hasCommitId() Checks

The hasCommitId() method runs a select, so it goes to the same server as your other reads in that request. In these cases, that is the primary:

  • The call runs inside a database transaction
  • The connection has sticky enabled, and the request has already written
  • Reads are forced to the write connection with useWriteConnectionWhenReading() or usingWritePdo()

The primary always has its own commits, so the method returns true in these cases. The result is still correct for the reads that follow, because they go to the primary too.

A connection keeps one read PDO for the request. When the read section lists more than one host, Laravel picks a host when the connection opens, and the check and the queries after it run on that host. The next request might pick a different replica, so check again on each request instead of saving the result.

Read Your Own Writes With Session Middleware

This middleware follows the same steps as PreventStaleDatabaseReads, but it stores a commit ID instead of a timestamp. On each request it asks the replica whether it has caught up. If the replica has the write, the request reads from the replica and the session key is removed. If not, the request reads from the primary:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

class ReadYourOwnWrites
{
    public function handle(Request $request, Closure $next): Response
    {
        $pending = $request->session()->get('db_commit_id');

        if ($pending !== null) {
            if (DB::hasCommitId($pending)) {
                $request->session()->forget('db_commit_id');
                $pending = null;
            } else {
                DB::useWriteConnectionWhenReading();
            }
        }

        $response = $next($request);

        if (DB::hasModifiedRecords()) {
            $commitId = DB::getLastCommitId();

            $request->session()->put('db_commit_id', $pending === null
                ? $commitId
                : DB::mergeCommitIds([$pending, $commitId]));
        }

        return $response;
    }
}

Append it to the web group in bootstrap/app.php, so it runs inside the session middleware:

use App\Http\Middleware\ReadYourOwnWrites;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        ReadYourOwnWrites::class,
    ]);
})

In a post edit flow, the PUT request updates the post, and the middleware stores the primary's commit ID in the session. The redirected GET asks the replica for that ID. With a lag of 50 milliseconds, the replica usually has the update by then, so the page reads from the replica. With a lag of 10 seconds, the GET and the requests after it read from the primary until the replica catches up.

When the user writes again before the first ID clears, mergeCommitIds() combines the two, so the session holds one ID that covers both writes.

The middleware uses an immediate check. A wait value in a web request would hold the response open while the replica catches up, and the primary can answer the reads in that time instead.

Each request with a pending ID runs one extra query on the replica, and each request that writes runs one extra query on the primary. The ID stays in the session until a replica has it. If replication stops, that user keeps reading from the primary and still sees current data.

Wait for a Replica in a Queued Job

A job dispatched right after a write can start before the replica has the row. A queue worker has no session, so pass the commit ID to the job and wait a short time in handle():

use App\Jobs\SendOrderConfirmation;
use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($request) {
    $order = Order::create([...]);
    $order->lines()->createMany([...]);

    return $order;
});

SendOrderConfirmation::dispatch($order->id, DB::getLastCommitId());

Call getLastCommitId() after the transaction commits. Inside the transaction, the primary has not committed the order yet, so the ID does not include it.

namespace App\Jobs;

use App\Mail\OrderConfirmation;
use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;

class SendOrderConfirmation implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $orderId,
        public string $commitId,
    ) {}

    public function handle(): void
    {
        if (DB::hasCommitId($this->commitId, wait: 2)) {
            $this->send();

            return;
        }

        DB::usingWritePdo(fn () => $this->send());
    }

    protected function send(): void
    {
        $order = Order::with('lines', 'customer')->findOrFail($this->orderId);

        Mail::to($order->customer)->send(new OrderConfirmation($order));
    }
}

The job takes the order ID, not the Order model. With the SerializesModels trait, Laravel loads a model property from the database before handle() runs. That query goes to a replica before the commit ID check, and on a lagging replica it throws a ModelNotFoundException.

The fallback uses usingWritePdo() instead of useWriteConnectionWhenReading(). A queue worker reuses its database connection across jobs, and usingWritePdo() restores the previous setting when the callback returns, so later jobs still read from the replicas.

Further Reading

Tobias Petry contributed commit IDs in #62198.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Sponsored

serpapi logo
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi

The latest

View all →
UUID Request Input and Migration Failure Events in Laravel 13.42 image

UUID Request Input and Migration Failure Events in Laravel 13.42

Read article
Replay Missed Laravel Scheduled Tasks With Schedule Catchup image

Replay Missed Laravel Scheduled Tasks With Schedule Catchup

Read article
Search Polymorphic Relations and Filament Tables With Laravel Searchable image

Search Polymorphic Relations and Filament Tables With Laravel Searchable

Read article
Laravel Rest Api 3.0: Typed Fields, Metrics, and OpenAPI Docs image

Laravel Rest Api 3.0: Typed Fields, Metrics, and OpenAPI Docs

Read article
Real-Time Inertia Props With Laravel Reverb image

Real-Time Inertia Props With Laravel Reverb

Read article
PhpStorm 2026.3 Adds an Agent Workspace and PHP 8.6 Support image

PhpStorm 2026.3 Adds an Agent Workspace and PHP 8.6 Support

Read article