Your Laravel App Is Showing Users Old Data After They Save. Laravel 13.38 Has a Fix for Replica Lag

How PreventStaleDatabaseReads and DB::usingWritePdo() work, where read-your-own-writes breaks with replicas, and what each option costs you.


A user edits the title of a post and sees the green “Saved” banner. They reload the page. The old title is back. They edit it again, or they file a bug report, or they decide your app loses data. Nothing was lost: the write landed on the primary database in milliseconds, and the page they reloaded was served from a read replica that hadn’t caught up yet. That’s the scenario the author of Laravel’s new middleware described in the pull request, and the numbers behind it are what make it a real production problem rather than a theoretical one: his replicas lagged 0 to 1 seconds on average and occasionally spiked as high as 15.

Laravel 13.38, released October 20, 2026, adds two tools for this, both from Jack Bayliss: a PreventStaleDatabaseReads middleware (#62078) and a usingWritePdo() method on the database connection (#62099). They’re good additions. They also each cover a narrower slice of the problem than their names suggest, and that gap is where stale reads keep happening after the upgrade. This post explains how both work, where read-your-own-writes still breaks, and what each option costs.

A note on sources. The mechanics below come from the official Laravel documentation, the release notes, and the pull request. The places where it keeps breaking draw on one independent test by Hafiz (hafiz.dev), who ran a MySQL primary and a deliberately delayed replica against a clean 13.38 project. I haven’t reproduced those results myself, so I’ve marked them as reported and told you how to check them on your own stack.


Why a Replica Returns Old Data

A read replica copies changes from the primary asynchronously. Between the moment a write commits and the moment the replica applies it, a read against the replica returns the previous value. Laravel’s read/write connection config sends SELECTs to the replica and writes to the primary:

// config/database.php
'mysql' => [
    'read'  => ['host' => ['replica-1.internal']],
    'write' => ['host' => ['primary.internal']],
    'sticky' => true,
    // driver, database, credentials...
],

The sticky option already exists for a reason: once a request has written, the rest of that same request reads from the primary, so a controller that saves and then queries sees its own change. It stops there. The redirect after a form submission is a new request, with a fresh connection that hasn’t written anything, so it goes straight back to the replica. That’s the exact gap 13.38 targets: the request after the write.


PreventStaleDatabaseReads: Carrying the Write Across Requests

The middleware is opt-in, so upgrading changes nothing until you add it. After a request writes to the default connection, it records a timestamp in the user’s session. For the next two seconds by default, that user’s reads on the default connection go to the primary. After the window, reads return to the replica.

// bootstrap/app.php
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        PreventStaleDatabaseReads::for(6), // seconds; see "The Window" below
    ]);
})

You can also scope it to specific routes with a custom window:

Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
    // routes where users edit then immediately re-read
});

The documented limits are worth stating plainly, because each one is a place a stale read can still get through:

It depends on the session, so it must run after StartSession. It only guarantees the user who wrote sees their own write; another user viewing the same post during the lag can still read the old value. It only tracks the default connection, so reads on any other connection aren’t redirected. And the author noted it’s session-based, so it isn’t a fit for stateless API routes.

The Window Has to Outlast Your Lag

A window shorter than your replica lag lets the old data through, and in a nastier way than before: the user sees the new value for two seconds, then the old one reappears when the window closes, then the new one again when the replica catches up. Size it from your worst observed lag, not your average. On MySQL, Seconds_Behind_Source in SHOW REPLICA STATUS is the number to watch; with the PR author’s 15-second spikes, a 2-second default would be far too short.

The window also reopens on every write. If something in your stack writes to the default connection on every request, like updating a last_seen_at column, an active user stays pinned to the primary for as long as they keep clicking. That may be fine or may quietly erase the benefit of having replicas at all, so check what your own requests write.

Reported: Middleware Order Matters

The docs show appending the middleware to the web group. The independent test reported that, set up exactly that way, a saved title still came back stale on a route with model binding. The reasoning holds up on paper: SubstituteBindings is in Laravel’s middleware priority list and PreventStaleDatabaseReads is not, so the appended middleware runs after route binding has already loaded the model from the replica. By the time reads switch to the primary, the model your controller received is old.

The reported fix is adding it to the priority list so it runs before the auth and binding middleware:

$middleware->prependToPriorityList(
    before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
    prepend: PreventStaleDatabaseReads::class,
);

The same test reported the user model loaded by auth had the same problem without it, so a profile save could leave the name in the nav bar wrong. Treat that as something to verify rather than assume. The quickest way is to reproduce it: run a replica with MySQL’s delayed replication (SOURCE_DELAY = 5), save something, and reload a route-bound page once a second while printing which server answered.


DB::usingWritePdo(): Precision for a Block of Code

Where the middleware is a blanket window per user, usingWritePdo() is a scalpel. It runs a callback with every read on that connection sent to the primary, then restores the previous setting, even if the callback throws.

$users = DB::connection()->usingWritePdo(
    fn ($connection) => $connection->table('users')->get()
);

The reason it exists is a gap in the older onWriteConnection(), which only applies to the query it starts. Anything loaded afterward goes back to the replica:

// ❌ The post comes from the primary; the relation does not
$post = Post::onWriteConnection()->findOrFail($id);
$post->comments; // lazy-loaded on a replica

// ✅ Everything inside the callback reads the primary
$post = DB::connection()->usingWritePdo(
    fn () => Post::with('comments')->findOrFail($id)
);

One thing it doesn’t need to be used for: queries inside a database transaction already use the write connection, so a block wrapped in DB::transaction() gets primary reads without usingWritePdo().


Where Read-Your-Own-Writes Still Breaks

Everything here follows from one fact: the middleware is a session-keyed switch on the default connection. Anything that doesn’t carry that session, or doesn’t use that connection, falls outside it. The independent test reported three concrete cases.

Stateless API routes. There’s no session to store the timestamp in. The class reads the session directly, so putting it in the api group reportedly fails with a “Session store not set” error rather than quietly doing nothing. An API client that saves and reads straight back gets the old value for the length of the lag. One reported variation: Sanctum looks up a token with an ordinary query, so a token issued moments ago could return 401 until the replica caught up. If your API clients read immediately after writing, the cheapest fix is returning the saved resource in the PUT/POST response so they don’t need the second read.

Queued jobs. A worker is a separate process with its own connection, and none of the request’s session or sticky state travels with the job. Laravel does restore the dispatched model from the primary, but relations and any query inside handle() can read the replica. A small job middleware closes that for the jobs that run right after a write:

class UsesPrimaryReads
{
    public function handle(object $job, Closure $next): mixed
    {
        return DB::connection()->usingWritePdo(fn () => $next($job));
    }
}

// In the job
public function middleware(): array
{
    return [new UsesPrimaryReads];
}

The test also reported that relations stored in the job payload are restored during unserialization, before job middleware runs, so the WithoutRelations attribute (or passing an id) matters. And one thing to be careful about when testing this: with the database queue driver, reserving a job is itself a write on the replicated connection, which can accidentally make a job read from the primary and hide the bug. Test with Redis or SQS to see the real behavior. Wrap only jobs dispatched right after a write; a report that runs ten minutes later doesn’t need the primary. If a job can wait, ->delay(now()->addSeconds(10)) is free.

The database cache store. A fresh Laravel 13 project defaults CACHE_STORE to database. The reported finding: the cache store reads through the connection with no write-PDO override, so on a replicated connection, Cache::get() can hit the replica. That matters most for the rate limiter. The increment goes to the primary, but the check that decides whether to refuse reads the count from the replica, so for the length of the lag a client could exceed the limit; the test measured 12 allowed requests against a limit of 3 per minute with a 5-second lag. The middleware can’t help here, because an attacker simply doesn’t send the session cookie. Point the cache at a connection that has no replica:

// config/database.php: a connection with no read/write split
'mysql_primary' => [
    'driver' => 'mysql',
    'host' => env('DB_WRITE_HOST'),
    // database, credentials...
],
DB_CACHE_CONNECTION=mysql_primary
DB_CACHE_LOCK_CONNECTION=mysql_primary

A Redis cache avoids the problem as well. If you do only one thing from this post, do this one, because a rate limiter that can be outrun is a security bug, not a display glitch.


What Each Option Costs

OptionCoversDoesn’t coverCost
stickyReads later in the same request as a writeThe next requestNone
PreventStaleDatabaseReadsThe writing user’s next requests, default connection, session routesOther users, other connections, stateless APIs, jobs, cacheThe user’s reads move to the primary for the window; any write reopens it
usingWritePdo()Every read inside the callback, including relationsAnything you forgot to wrapYou must remember it at each call site
onWriteConnection()The single query it startsRelations loaded afterwardNone, but narrow
Cache on a primary-only connectionRate limiter and lock readsNothing elseCache reads leave the replica

Every row except sticky trades replica offload for consistency: each read you redirect is a read the replica no longer absorbs. That’s the right trade for a user’s own just-written data, and the wrong one if applied everywhere. A long window is also a sign worth reading: if your lag is routinely several seconds, a bigger window is hiding a replication problem, not solving one.


Which One to Reach For

Does the user need to see their own change on the NEXT page load?
 ├─ Session route (web/Inertia)
 │     → PreventStaleDatabaseReads::for(N), N > worst observed lag
 │       Check its position in the middleware priority list
 ├─ Stateless API
 │     → Return the saved resource in the write response
 │       Otherwise key a primary-read flag on the user (not the session)
 └─ Background job dispatched right after a write
       → Job middleware wrapping usingWritePdo(), or delay the job

Does ONE block of code need fresh data across several queries/relations?
 └─ usingWritePdo(); one query only → onWriteConnection()

Regardless:
 └─ Is the cache/rate limiter on the replicated connection?
       → Move it to a primary-only connection or Redis

The Verdict

PreventStaleDatabaseReads fixes the bug its author described: a user saves, reloads, and sees their change. It does so by pinning one user’s reads to the primary for a short window, and that framing tells you exactly where it stops, which is anywhere there’s no session, no default connection, or no user. The honest way to adopt it is to treat the middleware as one layer, size the window from your worst lag, confirm where it sits relative to route binding, and then separately deal with jobs, API clients, and the cache, none of which it reaches. Before you trust any of it, reproduce the problem on purpose: delay a replica by a few seconds on a laptop and click through your own app before your users do.

Leave a Reply

Your email address will not be published. Required fields are marked *