13 min read

Your Users Save and Still See Old Data: Laravel 13.38 Fixes Read Replica Lag, Except in 3 Places

I put the new stale-read middleware in front of a MySQL replica held 5 seconds behind. The setup from the docs still served the old title.

Your Users Save and Still See Old Data: Laravel 13.38 Fixes Read Replica Lag, Except in 3 Places

I changed a post title to "Q4 pricing update", got the green "Saved" banner, and the heading right under it still said "Q3 pricing update". The debug line on the same page said the request had read from the primary. That was Laravel 13.38.0, with the new PreventStaleDatabaseReads middleware installed exactly the way the docs show it.

After saving "Q4 pricing update", the banner confirms the save but the heading still shows "Q3 pricing update", on a request that read from the primary

The middleware is a good idea. Jack Bayliss wrote it after seeing his replicas lag 0 to 1 seconds on average and up to 15 seconds under some workloads. That's long enough for a user to edit a title, refresh, see the old one, and file a bug report. It shipped in v13.38.0 on 20 October.

To see what it covers, I set up a MySQL 8.4 primary and a replica in Docker, held the replica 5 seconds behind with delayed replication (SOURCE_DELAY=5), and pointed a clean Laravel 13.38.0 project at both. Five seconds is slow for a healthy replica. That's the point. It makes every gap reproducible on a laptop. All output below is real.

The documented setup needs one extra call before it protects the most common kind of page. And three things the middleware never touches still read stale data after you fix it. API routes, queued jobs, and the database cache store, which is where your rate limiter keeps its counts.

What sticky already did, and what 13.38 adds

Laravel has split reads and writes for years. You give the connection a read and a write server, and set sticky so a request that writes also reads its own writes:

'mysql' => [
    'driver' => 'mysql',
    'read' => ['port' => env('DB_READ_PORT', '3306')],
    'write' => ['port' => env('DB_WRITE_PORT', '3306')],
    'sticky' => true,
    // host, database, credentials...
],

Both of my servers run on 127.0.0.1, so only the port differs. In production you'd override host instead.

But sticky only lasts for one request. Save a form, redirect, and the GET that follows is a new request on a clean connection. It reads the replica.

PreventStaleDatabaseReads carries the write across requests through the session. This is the core of it, from the framework source:

$connection->useWriteConnectionWhenReading(
    $request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp()
);

$response = $next($request);

if ($connection->hasModifiedRecords()) {
    $request->session()->put('_database_modified_at', Date::now()->getTimestamp());
}

Three details in those lines come back later. It calls $request->session(), so it needs a session. It only touches the default connection. And it compares whole-second timestamps, so a 2 second window stays open for somewhere between 2 and 3 seconds.

The same release added usingWritePdo(). It runs a callback with every read on that connection sent to the primary, then restores the previous setting, even if the callback throws. Most of the fixes below use it.

The documented setup still served the old title

The docs tell you to append the middleware to the web group:

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

I did that, saved a new title, and reloaded the page every second with the same session cookie. The page prints its title and the @@server_id of whichever server answered a select in the route closure. Server 1 is the primary, server 2 the replica.

+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q3 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+4.5s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+5.6s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+6.7s GET  title="Q4 pricing update"  read from server_id 2 (replica)

The middleware did its job. For the first three reloads the query in the closure went to the primary. The title was stale anyway, because it came from $post, and $post came from route model binding.

Route model binding happens in SubstituteBindings, which is already in the web group. Appending puts the new middleware after it. Laravel does sort some middleware by a priority list, and SubstituteBindings is on that list. PreventStaleDatabaseReads isn't, so it keeps its appended position. By the time it switches the connection to the primary, the model has already been loaded from the replica.

So add it to the priority list:

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

    $middleware->prependToPriorityList(
        before: AuthenticatesRequests::class,
        prepend: PreventStaleDatabaseReads::class,
    );
})

AuthenticatesRequests is the contract the auth middleware implements, and it sits ahead of SubstituteBindings in Laravel's priority list. Going in before it means the user lookup that auth does reads the primary too. It still runs after StartSession, which it needs. This is the order Laravel resolved for GET /posts/{post} after the change:

EncryptCookies
AddQueuedCookiesToResponse
StartSession
ShareErrorsFromSession
PreventRequestForgery
PreventStaleDatabaseReads
SubstituteBindings

I tested the auth part too. A logged-in user renames themselves, then reloads a route behind auth that prints auth()->user()->name and the server that answered its own query. With the middleware only appended, as in the docs, the rename from "Hafiz R" to "Hafiz Riaz" didn't show:

+0.1s PUT  name="Hafiz Riaz"
+0.1s GET  Hafiz R (server_id 1)
+1.1s GET  Hafiz R (server_id 1)
+2.2s GET  Hafiz R (server_id 1)
+3.3s GET  Hafiz R (server_id 1)

That's the name in your nav bar staying wrong after a profile save, on requests that read the primary for everything else. With the priority call in place, the earlier rename from "Hafiz" to "Hafiz R" showed on the first reload:

+0.1s PUT  name="Hafiz R"
+0.1s GET  Hafiz R (server_id 1)
+1.1s GET  Hafiz R (server_id 1)
+2.2s GET  Hafiz R (server_id 1)
+3.3s GET  Hafiz R (server_id 1)

Back to the post title. Same save, same reloads:

+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+4.5s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+5.6s GET  title="Q3 pricing update"  read from server_id 2 (replica)
+6.7s GET  title="Q4 pricing update"  read from server_id 2 (replica)

Better, and in one way worse. The user now sees the new title, then the old one again, then the new one. The default window closed after about 3 seconds, and my replica needed about 6.

Stale read window vs replica lag After a save, reloads read the primary only while the PreventStaleDatabaseReads window is open. With the default window the replica is still behind when the window closes, so three reloads show the old value again. A six second window outlasts the lag and every reload shows the new value. Primary write connection Replica SOURCE_DELAY=5 Reloads default window Reloads ::for(6) PUT saves Q4, session stamped still serving Q3 has Q4 window closed, replica still behind Q4 Q4 Q4 Q3 Q3 Q3 Q4 Q4 Q4 Q4 Q4 Q4 Q4 Q4 0s 1s 2s 3s 4s 5s 6s 7s time after save LEGEND fresh read (Q4) stale read (Q3) window: reads use the primary replica behind the primary

The window has to outlast your replica lag. for() sets it in seconds:

$middleware->web(append: [
    PreventStaleDatabaseReads::for(6),
]);

The priority entry can stay as the plain class name. I printed the resolved order again to check, and PreventStaleDatabaseReads:6 still landed before SubstituteBindings.

+0.0s PUT  title="Q4 pricing update"
+0.1s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+1.2s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+2.3s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+3.4s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+4.5s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+5.7s GET  title="Q4 pricing update"  read from server_id 1 (primary)
+6.8s GET  title="Q4 pricing update"  read from server_id 2 (replica)

With the middleware moved ahead of route binding and a 6 second window, the heading shows the new title straight after the save

Pick the number from your worst observed lag, not your average. On MySQL, Seconds_Behind_Source in SHOW REPLICA STATUS is the figure to watch. Jack's 15 second peak would need for(15) or more. Also, any write on the default connection during a request reopens the window. If a middleware updates last_seen_at on every request, an active user stays on the primary for as long as they keep clicking.

Place 1: API routes

A stateless API route has no session. Put the middleware in the api group and every request fails (output trimmed):

{
    "message": "Session store not set on request.",
    "exception": "RuntimeException",
    ...
}

Jack flags this in the PR himself ("It's session based, so not ideal for API stuff"). Leave it off, and an API client that saves and reads straight back gets the old value for the whole lag. That part I expected. Sanctum was the surprise.

New Sanctum tokens fail until the replica catches up

Sanctum looks up a personal access token with a plain find(), which reads the replica. So a token issued a moment ago doesn't exist yet as far as the next request can tell:

+0.1s POST /api/tokens -> new token issued
+0.1s GET  /api/me -> {"message":"Unauthenticated."} 401
+1.2s GET  /api/me -> {"message":"Unauthenticated."} 401
+2.3s GET  /api/me -> {"message":"Unauthenticated."} 401
+3.3s GET  /api/me -> {"message":"Unauthenticated."} 401
+4.4s GET  /api/me -> {"message":"Unauthenticated."} 401
+5.5s GET  /api/me -> {"message":"Unauthenticated."} 401
+6.5s GET  /api/me -> {"name":"Hafiz"} 200

A mobile app that logs in and immediately loads the profile screen hits exactly this. I fixed it with a token model that tries the replica first and only falls back to the primary on a miss:

namespace App\Models;

use Laravel\Sanctum\PersonalAccessToken as SanctumToken;

class PersonalAccessToken extends SanctumToken
{
    public static function findToken($token)
    {
        return parent::findToken($token) ?? (new static)->getConnection()->usingWritePdo(
            fn () => parent::findToken($token)?->load('tokenable')
        );
    }
}
// AppServiceProvider::boot()
Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class);

My first version had no load('tokenable'). Login worked, but a signup that returned a token still got 401 for 6 seconds. On signup the user row is as new as the token, and Sanctum loads the user later, outside the callback, from the replica. Loading it inside the callback fixed both flows. (If you're still choosing an auth package, Passport vs Sanctum covers that decision.)

Sanctum gets one thing right here already. When it updates last_used_at, it puts the connection's "modified" flag back the way it found it, so an authenticated GET doesn't count as a write.

Read-your-writes for API clients

For the save-then-read case I wrote a version of the middleware keyed by the authenticated user instead of the session:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

class PreventStaleApiReads
{
    public function handle(Request $request, Closure $next, int $seconds = 2)
    {
        if (! $user = $request->user()) {
            return $next($request);
        }

        $connection = DB::connection();
        $key = 'recent-write:'.$user->getAuthIdentifier();

        $connection->useWriteConnectionWhenReading(Cache::has($key));

        $response = $next($request);

        if ($connection->hasModifiedRecords()) {
            Cache::put($key, true, $seconds);
        }

        return $response;
    }
}

It needs the user, so it has to run after auth and before route binding:

$middleware->appendToPriorityList(
    after: AuthenticatesRequests::class,
    append: PreventStaleApiReads::class,
);

// routes/api.php
Route::middleware(['auth:sanctum', PreventStaleApiReads::class.':6'])->group(function () {
    Route::get('/posts/{post}', /* ... */);
    Route::put('/posts/{post}', /* ... */);
});

A token client with no cookies, saving and then reading every second:

+0.0s PUT  {"title":"Saved by an API client"}
+0.1s GET  {"title":"Saved by an API client","server_id":1}
+1.2s GET  {"title":"Saved by an API client","server_id":1}
+2.3s GET  {"title":"Saved by an API client","server_id":1}
+3.3s GET  {"title":"Saved by an API client","server_id":1}
+4.4s GET  {"title":"Saved by an API client","server_id":1}
+5.5s GET  {"title":"Saved by an API client","server_id":1}
+6.7s GET  {"title":"Saved by an API client","server_id":2}

The flag lives in the cache, so the cache must not read from the replica either. If it does, Cache::has() misses for the whole lag and the middleware does nothing. That's place 3.

Place 2: Queued jobs

A job runs in a worker process with its own connection. No session, no HTTP middleware, and none of the sticky state from the request that dispatched it. (It's the same reason tenant context goes missing in workers, one of the three multi-tenancy queue bugs I wrote about in June.)

Laravel already covers part of this. When a worker rebuilds a model from the job payload, restoreModel() calls useWritePdo(), so $this->post itself comes from the primary. Relations don't. The relations you eager-loaded before dispatching are restored with loadMissing(), which reads the replica. So does every query inside handle().

My test job logs the post title, the comment count through the restored relation, and a direct count query. The route adds a comment, then dispatches the job straight away with $post->load('comments'). The primary had 3 comments when the job ran:

SummarisePost {"title":"Saved through the API","comments_relation":2,"comments_query":2}

Fresh title. Both counts stale.

My first run didn't show this at all, and it took me a minute to see why. I was on the default database queue driver, and reserving a job runs an UPDATE on the jobs table. That marks the connection as modified, and with sticky on, everything the job reads afterwards goes to the primary. When I switched the queue to Redis, the stale reads appeared. Any driver that doesn't write to your replicated connection (Redis, SQS) behaves the same way. The 13.36 option Worker::$flushStickyConnections (#61915) doesn't change the database-driver case either, because the worker flushes before it reserves the next job, and the reserve marks the connection again.

The fix I'd use is a job middleware:

namespace App\Jobs\Middleware;

use Closure;
use Illuminate\Support\Facades\DB;

class ReadFromPrimary
{
    public function handle(object $job, Closure $next): mixed
    {
        return DB::connection()->usingWritePdo(fn () => $next($job));
    }
}
class SummarisePost implements ShouldQueue
{
    use Queueable;

    public function __construct(
        #[WithoutRelations]
        public Post $post,
    ) {
    }

    public function middleware(): array
    {
        return [new ReadFromPrimary];
    }

    // handle() unchanged
}
SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":4}

Job middleware runs after the job has been unserialized, so without #[WithoutRelations] a relation stored in the payload is already loaded from the replica by then. Leaving the attribute off, with 5 comments on the primary, I got this:

SummarisePost {"title":"Saved through the API","comments_relation":4,"comments_query":5}

With it, nothing is restored early, and the relation loads lazily inside the middleware's callback.

Put ReadFromPrimary on the jobs you dispatch right after a write, not on every job. A report that runs ten minutes later doesn't need it, and routing every job to the primary defeats the point of having a replica. If a job can wait, ->delay(now()->addSeconds(10)) costs nothing at all. Workers have enough lifecycle rules of their own already (like the SIGTERM they get on every deploy), and if you're tuning them for volume, processing 10,000 queue jobs without breaking covers the worker side.

Place 3: The database cache store, and your rate limiter

A fresh Laravel 13 project ships with CACHE_STORE=database on the default connection. The database cache store reads through that connection with no write-PDO override, so on a replicated connection, cache reads hit the replica. Sessions don't have this problem, because DatabaseSessionHandler adds useWritePdo() to its queries. The cache store doesn't.

Rate limiting is where that hurts. I put throttle:3,1 on a stateless login route and sent a request every half second:

+0.1s attempt 1 -> 200
+0.7s attempt 2 -> 200
+1.3s attempt 3 -> 200
+1.9s attempt 4 -> 200
+2.5s attempt 5 -> 200
+3.1s attempt 6 -> 200
+3.7s attempt 7 -> 200
+4.3s attempt 8 -> 200
+4.9s attempt 9 -> 200
+5.5s attempt 10 -> 200
+6.1s attempt 11 -> 200
+6.7s attempt 12 -> 200
+7.3s attempt 13 -> 429
+7.9s attempt 14 -> 429
+8.4s attempt 15 -> 429
+9.0s attempt 16 -> 429
allowed: 12 of 16 (limit is 3 per minute)

The increments run inside a locked transaction, so they go to the primary and the counter itself is right. But tooManyAttempts() reads the count with a plain cache get, from the replica, which still shows the count from 5 seconds ago. The limiter only starts refusing once the replica has caught up with the third hit. So an attacker gets the limit plus whatever they can send during the lag. At two requests a second that was 12 instead of 3, and a script sends a lot more than two. PreventStaleDatabaseReads doesn't help, because an attacker just drops the session cookie.

Point the cache at a connection without a replica:

// config/database.php
'mysql_primary' => [
    'driver' => 'mysql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_WRITE_PORT', '3306'),
    'database' => env('DB_DATABASE', 'laravel'),
    'username' => env('DB_USERNAME', 'root'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
],
DB_CACHE_CONNECTION=mysql_primary
DB_CACHE_LOCK_CONNECTION=mysql_primary

In production that connection gets your primary's host. Same burst afterwards:

allowed: 3 of 16 (limit is 3 per minute)

A Redis cache avoids the problem as well. Either way, the PreventStaleApiReads flag from place 1 depends on this.

What I'd ship

Move the cache off the replicated connection first, even if you skip everything else in this post. A rate limiter that can be outrun is a security bug, not a UX one, and it's a two-line .env change.

Then add PreventStaleDatabaseReads with the priority call and a window longer than your worst lag. Without the priority call it protects the queries in your controllers and misses the model your route is named after.

For APIs, the token fallback is cheap. It costs one extra primary query, and only when the replica misses. The user-keyed middleware is worth it only if your clients read right after they write. If your PUT responses already return the saved resource, your clients may never need it.

For jobs, wrap the few that run straight after a write and leave the rest alone.

Every one of these moves reads onto the primary that the replica was supposed to take. That's the trade. If your lag is routinely more than a few seconds, a longer window hides a replica problem rather than fixing it.

FAQ

Does PreventStaleDatabaseReads stop other users from seeing stale data?

No. It only sends reads to the primary for the session that made the write. Another user viewing the same post during the lag still reads the replica. That's usually fine, because they never saw the new value to begin with.

Do I still need the sticky option?

Yes. They cover different requests. sticky sends reads to the primary later in the same request that wrote. The middleware covers the requests that come after. I had both on for every test in this post.

Does it cover a second database connection?

No. It only tracks and switches the default connection, and the docs say so. If tenant or reporting data lives on another connection, wrap those reads in that connection's usingWritePdo().

Why does a 2 second window last longer than 2 seconds?

The middleware stores and compares whole-second timestamps. A write at 10.0 and one at 10.9 are both stored as 10, and the window closes when the clock reaches 13. So it stays open between 2 and 3 seconds after the write. In my runs, a reload at 2.3 seconds still went to the primary with the default window.

Does the database queue driver protect jobs from stale reads?

By accident, when the jobs table lives on the same replicated connection and sticky is on. Reserving the job is a write, so the job reads the primary. Don't rely on it. Switch to Redis or SQS later and the stale reads show up with no code change.

Conclusion

PreventStaleDatabaseReads fixes the bug Jack described, as long as you add the priority call and size the window to your lag. Both are one-line fixes. Everything else in this post sits outside what a session-based middleware can reach, and none of it needed production traffic to show up. If you run read replicas, start one with SOURCE_DELAY=5 on your laptop and click through your own app before your users do.

Share: X/Twitter | LinkedIn | | RSS
Hafiz Riaz

About Hafiz

Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.

Get in touch →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime