CodeSapient
← All articles

Laravel Read Replicas: How to Prevent Stale Reads After a Write

Why Laravel users see old data after saving when you use read replicas, and how to fix it with sticky connections, PreventStaleDatabaseReads, DB::usingWritePdo() and queue worker resets, with lab results.

By CodeSapient Labs ·
Comic book illustration of a user saving a post to the writer database, a lagging read replica returning a stale title, and the PreventStaleDatabaseReads middleware serving the fresh title
Comic book illustration titled Laravel Read Replicas in three panels: a user saves a post title to the writer database, the redirect reads an old title from a lagging read replica with a STALE burst, and the PreventStaleDatabaseReads middleware keeps the reads on the writer for two seconds so the fresh title appears

Adding a read replica is one of the cheapest ways to take load off a busy Laravel database. It also introduces a bug that every team meets sooner or later: a user edits a record, presses Save, lands back on the page and sees the old value. Nothing is broken. The replica simply has not replayed the change yet, and the redirect read from it.

Until recently the fixes were home-made: forcing onWriteConnection() on individual queries, custom middleware, or giving up on replicas for whole sections of the app. Laravel 13.38.0, released on October 20, 2026, added two framework-level tools, the PreventStaleDatabaseReads middleware and DB::usingWritePdo(), and Laravel 13.36 added an opt-in reset of sticky connections between queue jobs. This guide explains where stale reads come from in Laravel, which tool fixes which case, and what we measured when we tested all of them against a real PostgreSQL replica with controlled lag.

How Laravel read and write connections work

Laravel splits traffic when a connection in config/database.php has read and write keys. Everything else in the connection array is shared, and each side only overrides what differs, usually the host. The database documentation notes that when several read hosts are listed, one is chosen at random for each request.

'pgsql' => [
    'driver' => 'pgsql',
    'read' => [
        'host' => ['10.0.0.11', '10.0.0.12'],
    ],
    'write' => [
        'host' => ['10.0.0.10'],
    ],
    'sticky' => true,
    'database' => env('DB_DATABASE', 'laravel'),
    'username' => env('DB_USERNAME', 'laravel'),
    'password' => env('DB_PASSWORD', ''),
    // ...
],

Three things decide where a given SELECT goes:

  • Writes (INSERT, UPDATE, DELETE) always use the write connection.
  • Reads use a read host, unless the query asked for the writer with onWriteConnection() / useWritePdo(), or the connection has been switched to the writer for reads.
  • Transactions run every query on the writer, so code inside DB::transaction() already reads its own writes.

The sticky option is the first layer of protection. The connection keeps a flag that records whether this connection has modified any rows. Once it is set, later reads on that connection go to the writer. The catch is scope: in a normal PHP-FPM request the connection, and therefore the flag, lives for exactly one request.

Why users still see stale data with sticky enabled

The classic form flow is two HTTP requests. The PUT or POST writes and returns a redirect. The browser then issues a GET, which starts with a fresh connection and a clear sticky flag, so it reads from a replica. If replication lag is longer than the time between those two requests, which is usually a fraction of a second, the page shows the previous value.

There are three other places the same problem hides:

  • Single queries forced to the writer. onWriteConnection() only applies to the query it starts. A relationship loaded from the result builds a new query, and that one goes to a replica.
  • Code reacting to someone else's write. A job triggered by a payment webhook, or an admin screen opened right after an import, is not the request that wrote the data, so sticky never applies.
  • Long-running processes. In a queue worker the connection lives for many jobs. Here the problem is reversed: one write pins every later job to the writer until the worker restarts.

The fix for the next request: PreventStaleDatabaseReads

The new middleware carries the sticky behaviour across requests for the user who made the change. Append it to the web group in bootstrap/app.php, as shown in the Laravel 13 documentation:

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->web(append: [
        PreventStaleDatabaseReads::for(5),
    ]);
})

Or limit it to the routes that need it while you evaluate it:

Route::middleware(['auth', PreventStaleDatabaseReads::class])->group(function () {
    Route::get('/posts/{post}', [PostController::class, 'show']);
    Route::put('/posts/{post}', [PostController::class, 'update']);
});

The implementation is short enough to read in one sitting, and knowing it helps you configure it correctly:

  1. Before the controller runs, it reads _database_modified_at from the session. If that timestamp falls inside the window (2 seconds unless you pass a value to for()), every read on the default connection for this request uses the writer.
  2. After the response is built, it checks whether the request modified any records. If it did, it stores the current time in the session.

That design has consequences worth writing down for your team:

  • It needs a session. It must run after StartSession, so it fits the web group. Token-authenticated APIs without sessions get no benefit and need one of the other techniques below.
  • It only protects the writer's own session. Other users can still read the old value from a replica until it catches up. For most apps that is acceptable; for shared dashboards it may not be.
  • It only tracks the default connection. If your tenant data or reporting data lives on a second named connection, the middleware does nothing for it.
  • The timestamp has one-second resolution. The session stores a Unix timestamp in whole seconds and compares it against "now minus N seconds", so the effective window is somewhere between N and N+1 seconds depending on where in the second the write happened. Size the window from your lag, not from the edge of it.

We also checked one worry: if sessions themselves live in the database, could the session row be read from a lagging replica, losing the timestamp? No. Laravel's DatabaseSessionHandler builds its queries with useWritePdo(), so session reads always go to the writer.

The fix for a block of code: DB::usingWritePdo()

When freshness matters regardless of who made the change, for example a job that runs right after a webhook updates an order, or a checkout step that re-reads stock, wrap the reads in usingWritePdo():

use Illuminate\Support\Facades\DB;

$drafts = DB::usingWritePdo(function () use ($email) {
    $user = User::query()->where('email', $email)->first();

    return $user?->posts()->whereNull('published_at')->get();
});

Every read inside the callback, including relationship and lazy-loaded queries on the same connection, goes to the writer. The method returns the callback's result and restores the previous read preference when the callback finishes, even if it throws. Compare that with User::onWriteConnection()->first(), where only the users query hits the writer and the posts() query that follows can come back from a replica that has not seen the new rows. We reproduced exactly that in the lab below.

Use usingWritePdo() sparingly and deliberately. Every read you move to the writer is load the replicas were supposed to absorb. A good rule: wrap the specific read-after-write paths, not whole controllers.

Queue workers: Worker::$flushStickyConnections

A worker started with queue:work is one long-lived PHP process that keeps its database connection between jobs. Before Laravel 13.36 the worker never cleared the sticky flag, so a single job that wrote a row sent the reads of every following job to the writer until the worker hit --max-jobs, --max-time, its memory limit, or a queue:restart. As Laravel News explains, 13.36 added an opt-in reset:

// app/Providers/AppServiceProvider.php
use Illuminate\Queue\Worker;

public function boot(): void
{
    Worker::$flushStickyConnections = true;
}

With it enabled, the worker calls forgetRecordModificationState() on every open connection before taking the next job, while sticky still works inside a job. It is off by default because a job might, by accident, rely on reading what the previous job in the same worker wrote. Jobs with that dependency should be a chain or a batch, and should use usingWritePdo() for the read.

One trap if you use the database queue driver: reserving a job is an UPDATE on the jobs table. If that table is on your default connection, the reservation sets the sticky flag again right after the reset, and every job reads from the writer anyway. Point DB_QUEUE_CONNECTION at a separate, writer-only connection. The Redis and SQS drivers do not have this issue because they do not write to your database. Octane already resets the flag between requests, and custom long-running loops can call DB::forgetRecordModificationState() themselves.

Comic panel chart titled Which fix for which stale read, with four cards: sticky true for writes and reads in the same request, PreventStaleDatabaseReads for the same user on next requests, DB usingWritePdo for one block of code from any caller, and Worker flushStickyConnections for queue workers
Each tool covers a different scope. Most production apps need two or three of them.

What we measured in a real replica lab

We wanted to see the behaviour rather than infer it, so we built a small lab: Laravel 13.38.0 on PHP 8.4.27, a PostgreSQL 18.6 primary and a streaming standby on the same machine, with the standby's recovery_min_apply_delay set to create predictable lag. A script updated a post title, then read it back at fixed offsets in the same browser session and recorded the title and the readWriteType of the query. Each configuration ran three times. These are results from one machine with artificial lag, not production benchmarks, but the behaviour is deterministic enough to be useful.

Comic style results grid for a 3.5 second replica delay: with sticky only, reads at 0, 1 and 2.5 seconds were stale from the replica; with the 2 second middleware, reads at 0 and 1 second were fresh from the writer and at 2.5 seconds two were fresh and one stale; with the 5 second window every read was fresh
Read-after-write results with replay delayed by 3.5 seconds. W = writer, R = replica.
  • Sticky only. With 1.5 s of lag, the reads at +0 s and +1 s were stale in all three runs; by +2.5 s the replica had caught up. With 3.5 s of lag, reads stayed stale through +2.5 s. sticky never helped, because every read was a new request.
  • Middleware, default 2 s window. With 1.5 s of lag every read was fresh: the first two came from the writer, the rest from a caught-up replica. With 3.5 s of lag the +2.5 s read was the edge case: two runs still went to the writer, one had left the window and read stale data from the replica. A repeat with database-backed sessions returned stale at +2.5 s in all three runs. That is the whole-second window in action, and it is why a 2 s window is wrong for 3.5 s of lag.
  • Middleware with for(5). Every read was fresh at both lag levels, served by the writer up to +4 s and by the replica at +6 s.
  • Relationships. After inserting a draft and clearing the sticky flag, User::onWriteConnection()->find() followed by a posts() count ran write then read and returned 1 draft. The same code inside usingWritePdo() ran write, write and returned the correct 2.
  • Queue worker. One writing job followed by ten reading jobs: without the flag, all ten reads went to the writer. With flushStickyConnections on and the database queue on its own connection, all ten went to the replica. With the flag on but the jobs table on the default connection, all ten went to the writer again, exactly the trap described above.

Choose the window from your replica lag metrics

The middleware window should be longer than the lag you actually see at peak, not the average. Measure it where it is reported:

  • PostgreSQL: the replay_lag column of pg_stat_replication on the primary. The PostgreSQL monitoring docs note that for an asynchronous standby it approximates the delay before recent transactions became visible to queries.
  • MySQL: Seconds_Behind_Source in SHOW REPLICA STATUS.
  • Amazon RDS: the ReplicaLag CloudWatch metric, which for MySQL and MariaDB reports the Seconds_Behind_Master value from SHOW REPLICA STATUS.

Lag tends to spike during bulk imports, large migrations and long-running analytical queries on the replica. If your p99 lag is regularly above a few seconds, a longer window only hides the symptom: look at the write pattern, slow queries on the replica, and connection pressure first. Our guides on PostgreSQL slow query optimization and connection pooling for SaaS APIs cover those levers.

Testing which server a query used

Each entry in Laravel's query log includes a readWriteType key set to read or write, which makes routing testable:

public function test_reads_after_an_update_use_the_write_connection(): void
{
    $user = User::factory()->create();
    $post = Post::factory()->for($user)->create();
    DB::forgetRecordModificationState();

    $this->actingAs($user)
        ->put("/posts/{$post->id}", ['title' => 'New title'])
        ->assertRedirect();

    DB::forgetRecordModificationState();
    DB::enableQueryLog();

    $this->actingAs($user)->get("/posts/{$post->id}")->assertOk();

    $this->assertSame('write', DB::getQueryLog()[0]['readWriteType']);
}

Use DatabaseMigrations or DatabaseTruncation for these tests, not RefreshDatabase. RefreshDatabase wraps each test in a transaction, every query in a transaction uses the writer, and the assertion would pass even without the middleware.

Common mistakes

  • Turning on replicas without auditing read-after-write paths. List every flow where a user, job or webhook writes and then reads: forms with redirects, checkout, imports, permission changes. Decide the fix for each.
  • Forcing whole routes to the writer. It works, and it quietly removes the benefit of the replicas. Scope usingWritePdo() to the read that needs it.
  • Relying on the middleware for APIs or other users. It is per session and per default connection. clients using token auth, such as mobile apps, and cross-user views need explicit writer reads or a UI that tolerates eventual consistency, such as returning the updated resource in the write response.
  • Forgetting multi-tenant or secondary connections. If each tenant or module has its own named connection, the middleware does not cover it; see our notes on {"multi-tenant SaaS architecture"} for how connection layout affects this kind of decision.
  • Caching a stale read. If a replica read feeds a cache, the stale value can outlive the lag by minutes. Invalidate caches after the write, as covered in our {"Redis caching and invalidation guide"}, and do not repopulate them from a replica immediately after a write.
  • Running old framework or PHP versions. The middleware and usingWritePdo() need Laravel 13.38 or later. While upgrading, also check the October 2026 PHP security releases described in our {"PHP vulnerabilities guide"}.

Recommendations

  1. Keep sticky on. It is still the right default for reads inside the request that wrote.
  2. Add PreventStaleDatabaseReads to the web group with a window above your peak replica lag, plus a second of margin for the whole-second resolution.
  3. Use DB::usingWritePdo() in jobs and services that react to writes they did not make, especially webhook handlers and anything involving money or stock.
  4. Enable Worker::$flushStickyConnections once you have checked for jobs that depend on the previous job's writes, and keep the database queue on its own connection.
  5. Alert on replica lag and test routing with readWriteType assertions so a refactor cannot silently move reads.

FAQ

Does PreventStaleDatabaseReads send all traffic to the primary?

No. Only requests from a session that wrote within the window read from the writer, and only on the default connection. Everyone else keeps reading from the replicas, which is what keeps the load benefit intact.

Do I still need sticky if I use the middleware?

Yes. The middleware decides at the start of a request whether to use the writer for reads; sticky handles a write followed by a read inside the same request. They complement each other.

What about stateless APIs?

The middleware relies on the session, so token-based APIs need another approach: return the updated resource from the write endpoint, use usingWritePdo() on the reads that follow a write, or carry a "last write" marker yourself.

Is a long window risky?

It moves more reads to the writer for active editors. For typical apps where few users write at once that is negligible, but on write-heavy workloads measure primary load before raising it far.

Conclusion

Read replicas trade consistency for capacity, and Laravel now gives you precise tools to buy back consistency only where users notice it: sticky inside a request, PreventStaleDatabaseReads across the redirect, usingWritePdo() for code that reacts to other writes, and sticky flushing for queue workers. Size the window from real lag metrics, test the routing, and the "I saved it but it did not change" ticket goes away without giving up the replicas.

If you are scaling a Laravel or PHP application and want help with database topology, replicas or queue architecture, our web application development team works on this kind of backend performance work. Tell us about your setup and we will suggest where to start.

← Back to Blog