Laravel Read Replicas: Route Reads to a Follower Without Serving Stale Data

Configure a Laravel read replica with sticky reads, then fix the read-after-write bugs sticky misses in queue workers, sessions, cache locks and Octane.

Steven Richardson
Steven Richardson
· 11 min read

A user updates a setting, the next page shows the old value, and it only happens in production. You add 'sticky' => true, it goes away for a fortnight, then support gets the same ticket again — this time from a webhook handler. The config is the easy part. What follows is the set of read-after-write failures that sticky was never designed to cover, and the three escapes that actually fix each one.

Everything below is verified against laravel/framework v13.6.0.

Measure your current read/write split before changing anything#

Before you provision anything, find out whether reads are actually your constraint. A replica does nothing for a slow query — it runs the same slow query on a second box — and it makes read-after-write consistency strictly worse. Log every query with its type for an hour of real traffic and look at the ratio.

Laravel 13 added a readWriteType property to the QueryExecuted event, which makes this a two-line listener:

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function (QueryExecuted $query): void {
    Log::channel('query-audit')->info($query->sql, [
        // 'read', 'write', or null when no split is configured
        'pdo' => $query->readWriteType,
        'ms' => $query->time,
    ]);
});

If 95% of your query time is in a handful of unindexed SELECTs, fix those first. If you are reading the same rows on every request, Laravel's caching strategies will buy you more headroom than a replica, with no consistency cost. And if the problem is a controller firing 400 queries, turn on Eloquent strict mode to catch the N+1 instead. Replicas are for read volume on queries that are already fast.

Provision the replica and confirm it is actually replicating#

Stand the replica up, then verify it before Laravel ever touches it. On MySQL 8.4 the statement is SHOW REPLICA STATUSSHOW SLAVE STATUS is gone — and you want Replica_IO_Running, Replica_SQL_Running and Seconds_Behind_Source.

-- MySQL 8.4
SHOW REPLICA STATUS\G

-- PostgreSQL 17, run on the standby
SELECT
    pg_last_xact_replay_timestamp() AS last_replay,
    now() - pg_last_xact_replay_timestamp() AS replay_delay,
    pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS apply_lag_bytes;

Write a row on the primary, count to one, read it on the replica. That round trip is your real lag floor, and it is the number every decision in the rest of this article depends on. If there is a connection pooler in front of the replica, add it now rather than later — PgBouncer in transaction mode changes how prepared statements and sessions behave, and you do not want to be debugging that on top of a replication question.

Configure the read and write arrays in config/database.php#

Add read, write and sticky to the connection. Only put keys inside read and write that differ from the parent array — credentials, charset, prefix and options are merged from the top level.

'mysql' => [
    'driver' => 'mysql',

    'read' => [
        'host' => [
            env('DB_READ_HOST_1', '10.0.1.11'),
            env('DB_READ_HOST_2', '10.0.1.12'),
        ],
        // Only needed when the replica has its own credentials.
        'username' => env('DB_READ_USERNAME', env('DB_USERNAME')),
        'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD')),
    ],

    'write' => [
        'host' => env('DB_WRITE_HOST', '10.0.1.10'),
    ],

    'sticky' => true,

    'port' => env('DB_PORT', '3306'),
    'database' => env('DB_DATABASE', 'laravel'),
    'username' => env('DB_USERNAME', 'root'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => env('DB_CHARSET', 'utf8mb4'),
    'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
],

When read.host is an array, Laravel picks one host at random when the connection is resolved — not per query. A retried request can land on a different replica with different lag, which is one more reason idempotency matters here.

You do not need to do anything for migrations. Illuminate\Database\Schema\Builder calls selectFromWriteConnection() for schema introspection, and DatabaseMigrationRepository calls useWritePdo() on the migrations table, so DDL and the migration ledger are already pinned.

Turn on sticky and understand exactly what it covers#

Read the implementation rather than the docs, because the scope is the whole story. This is Illuminate\Database\Connection::getReadPdo():

public function getReadPdo()
{
    if ($this->transactions > 0) {
        return $this->getPdo();
    }

    if ($this->readOnWriteConnection ||
        ($this->recordsModified && $this->getConfig('sticky'))) {
        return $this->getPdo();
    }

    $this->latestPdoTypeRetrieved = 'read';

    // ...
}

Three facts fall straight out of that.

Any read inside an open transaction uses the write PDO, always, regardless of sticky. If a query returns the wrong row outside a transaction and the right row inside one, you are looking at replication lag, not a Laravel bug.

recordsModified is a boolean on the Connection object, flipped to true by recordsHaveBeenModified() whenever an INSERT, UPDATE, DELETE or raw statement runs. Sticky is therefore scoped to the lifetime of that PHP object — which in a normal FPM request happens to be the request, which is why the docs describe it as the request cycle. It is not scoped to a user, a session, or the next request.

And readOnWriteConnection is the manual override, which is the next section but one.

Find the reads that sticky will not protect#

Four boundaries, all of them a different process or a different Connection instance from the one that did the write.

A subsequent request. POST, redirect, GET. Two PHP processes, two Connection objects, recordsModified is false in the second one. If your write is fast and your redirect is faster, the GET reads a lagging replica. This is the most common report and the hardest to reproduce locally.

Queue jobs. A job dispatched from a request runs in a worker that never saw the write. afterCommit guarantees the transaction is committed before the job is pushed, which is necessary but not sufficient — a committed row on the primary is not a visible row on the replica.

Broadcast listeners and scheduled commands. Same shape as queue jobs, same fix.

Long-lived workers, in the opposite direction. Under queue:work or Octane the Connection object survives across jobs and requests, so recordsModified stays true after the first write until the process dies. Nothing in the framework resets it: grepping v13.6.0 for forgetRecordModificationState() finds the method definition and the DB facade docblock, and no caller. This is laravel/framework#37646, opened in 2021 and closed as needing more info. The consequence is that a write-heavy worker sends all subsequent reads to the primary, so the offload you budgeted for never happens.

If you run Octane in production, that last one applies to your web tier too.

Force authoritative reads with useWriteConnectionWhenReading#

There are three escapes, in ascending order of bluntness. Use the narrowest one that fixes the bug.

For a single query, useWritePdo() on the query builder, or onWriteConnection() on a model:

// Query builder
$balance = DB::table('accounts')->useWritePdo()->where('id', $id)->value('balance');

// Eloquent
$account = Account::onWriteConnection()->findOrFail($id);

For a block of code, flip the flag on the connection and put it back in a finally:

$connection = DB::connection('mysql');
$connection->useWriteConnectionWhenReading();

try {
    $this->reconcile($account);   // every read in here hits the primary
} finally {
    $connection->useWriteConnectionWhenReading(false);
}

For a model that must never be stale — an idempotency-key table, a ledger, anything with a uniqueness guarantee — pin it permanently. DatabaseManager::parseConnectionName() recognises the ::read, ::write and ::direct suffixes, so you can name the write side directly:

class IdempotencyKey extends Model
{
    protected $connection = 'mysql::write';
}

Be aware that mysql::write is cached as a separate Connection instance with its own PDO pair, so it doubles the connections that model opens, and a write through it does not set recordsModified on plain mysql. That is fine for a small pinned table and wasteful if you do it everywhere.

Worth knowing before you reach for any of these: fresh() and refresh() already call useWritePdo() internally, so $model->refresh() after a save is authoritative for free.

Route queued jobs to the write connection where correctness demands it#

This is where most of the real bugs live, and Laravel has already solved the common case for you. SerializesAndRestoresModelIdentifiers::restoreModel() — the trait behind SerializesModels — restores the model like this:

return $this->getQueryForModelRestoration(
    (new ($value->getClass()))->setConnection($value->connection), $value->id
)->useWritePdo()->firstOrFail()->loadMissing($value->relations ?? []);

useWritePdo(). So a job that type-hints an Eloquent model re-fetches it from the primary on unserialize. The advice you will find on older blog posts — "pass the id, not the model, and re-fetch inside the job" — is the less safe option in Laravel 13, because your manual Model::find($id) goes to the replica unless you say otherwise.

final class ChargeInvoice implements ShouldQueue
{
    use Queueable, SerializesModels;

    // Restored via useWritePdo() — authoritative, no extra work.
    public function __construct(public Invoice $invoice) {}

    public function handle(): void
    {
        // But a *related* read is not covered. Pin it explicitly.
        $customer = Customer::onWriteConnection()->find($this->invoice->customer_id);

        // ...
    }
}

The second half of the job problem is the sticky leak. Reset the flag when each job starts, so a worker that wrote in job 2 does not send job 3's reads to the primary:

// app/Providers/AppServiceProvider.php
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(function (JobProcessing $event): void {
        foreach (DB::getConnections() as $connection) {
            $connection->forgetRecordModificationState();
        }
    });
}

Dispatch with afterCommit as well — it does not solve replication lag, but it removes the transaction race that otherwise hides behind it. If your jobs fan out, the ordering guarantees in queue chains versus batches matter more once reads can be stale, and the worker-count and memory guidance in scaling Laravel queues in production applies unchanged.

Pin sessions, cache and locks to the write connection#

If any of these use the database driver, they must never read from a replica. A session read that misses logs the user out. A Cache::lock() checked against a lagging replica is not a lock. All three accept a connection name, so the ::write suffix is the whole fix:

// config/session.php
'connection' => env('SESSION_CONNECTION', 'mysql::write'),

// config/cache.php
'database' => [
    'driver' => 'database',
    'connection' => env('DB_CACHE_CONNECTION', 'mysql::write'),
    'lock_connection' => env('DB_CACHE_LOCK_CONNECTION', 'mysql::write'),
],

// config/queue.php
'database' => [
    'driver' => 'database',
    'connection' => env('DB_QUEUE_CONNECTION', 'mysql::write'),
],

Monitor replication lag and alert on it#

A replica that drifts minutes behind converts an occasional correctness bug into a support queue, and you will not notice from application metrics. Record the lag on a schedule and treat it as a first-class signal:

final class ReplicationLagCheck
{
    public function seconds(): ?float
    {
        $row = DB::connection('mysql::read')->selectOne('SHOW REPLICA STATUS');

        return $row?->Seconds_Behind_Source === null
            ? null                       // NULL means replication is stopped
            : (float) $row->Seconds_Behind_Source;
    }
}

Fail the check when the value is null, not just when it is high — null means the applier thread has stopped, which is unbounded lag rather than zero. Surface it next to your other numbers with a custom Pulse recorder, wire it into your existing observability stack, and expose it on the readiness endpoint if you are running Kubernetes health probes.

Verify the split with query logging and Pest#

Assert the behaviour rather than trusting it. QueryExecuted::$readWriteType makes this straightforward in Laravel 13:

use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;

function pdoTypesFor(Closure $callback): array
{
    $types = [];

    DB::listen(fn (QueryExecuted $q) => $types[] = $q->readWriteType);

    $callback();

    return $types;
}

it('reads from the replica by default', function () {
    expect(pdoTypesFor(fn () => Invoice::query()->get()))->toBe(['read']);
});

it('sends reads to the primary after a write when sticky is on', function () {
    config()->set('database.connections.mysql.sticky', true);

    $types = pdoTypesFor(function () {
        Invoice::factory()->create();
        Invoice::query()->get();
    });

    expect($types)->toBe(['write', 'write']);
});

it('restores a queued model from the primary', function () {
    $invoice = Invoice::factory()->create();

    DB::connection()->forgetRecordModificationState();

    $types = pdoTypesFor(fn () => unserialize(serialize(new ChargeInvoice($invoice))));

    expect($types)->toBe(['write']);
});

Add a middleware in staging that fails the response if any write query ran on a request you expected to be read-only. That catches the accidental write inside a GET long before it costs you the replica offload for the rest of the request.

Wrapping Up#

Configure the split, turn sticky on, then spend your time on the four boundaries it does not cover: the next request, the queue worker, the listener, and the long-lived process that never resets its flag. Add the JobProcessing listener, pin sessions and locks with ::write, and put replication lag on a dashboard before you route a single production read.

Once the split is live, the next thing to get right is the layer in front of it — PgBouncer transaction pooling if you are on Postgres, and expand/contract migrations so a schema change never arrives on the replica before the code that reads it.

FAQ#

How do I configure read replicas in Laravel?

Add read, write and sticky keys to a connection in config/database.php. The read and write arrays only need the keys that differ from the parent connection — typically just host — because credentials, charset, prefix and PDO options are merged from the top level. If read.host is an array, Laravel picks one host at random when the connection is resolved.

What does the sticky option do in Laravel database config?

Once any write runs on a connection, sticky sends every subsequent read on that same Connection object to the write host. Internally it is the recordsModified boolean checked inside getReadPdo(). In a normal FPM request that boundary is the request cycle, but the real scope is the lifetime of the connection object, which is why it behaves differently under Octane and queue workers.

Why is my Laravel app reading stale data from a read replica?

Almost always because the read happened in a different process from the write, so sticky never applied — a POST-redirect-GET, a queue job, a broadcast listener, or a scheduled command. Confirm it by running the same read inside DB::transaction(); transactional reads always use the write PDO, so if the data is correct there and wrong outside, it is replication lag rather than an application bug.

Do Laravel queue jobs respect the sticky database connection?

Not in the way people expect. A job runs in a separate process from the request that dispatched it, so a write in that request does not make the job's reads sticky. Worse, within a single long-lived worker the flag is never reset between jobs — Laravel 13.6 has no caller for forgetRecordModificationState() — so a write in one job sends every later job's reads to the primary until the worker restarts.

How do I force a single Eloquent query to use the write connection?

Use Model::onWriteConnection() for an Eloquent query or ->useWritePdo() on the query builder. For a block of code, call DB::connection('mysql')->useWriteConnectionWhenReading() and reset it in a finally. For a model that must never be stale, set protected $connection = 'mysql::write'; — Laravel resolves the ::write suffix to a connection whose read PDO is the write PDO.

Should I use read replicas or caching to reduce database load?

Cache first. Caching removes the query entirely and costs you nothing in read-after-write consistency, whereas a replica keeps the query and adds a lag window to every read. Replicas earn their place when you have a high volume of already-fast queries that genuinely tolerate being a few hundred milliseconds behind — reporting, search listings, public pages. They are not a fix for a slow query or a missing index.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.