Laravel Read Replicas: Prevent Stale Reads in 13.38

· Laravel, Development

Table of contents

You save a form, get redirected, and the page shows the old value. Reload, and the new one appears. If your Laravel app reads from a database replica, you have probably had this bug report, and probably closed it as "can't reproduce". Laravel 13.38, released on October 21, 2026, ships a first-party fix: a PreventStaleDatabaseReads middleware and a new DB::usingWritePdo() method. Here is why stale reads happen with a Laravel read replica setup, what the two additions do, and how I'm rolling them out on the apps I maintain.

Why stale reads happen with read replicas

Laravel has supported read/write splitting for years: you list read and write hosts in config/database.php, and Laravel sends SELECT queries to a replica and everything else to the primary. It is a cheap way to take load off the main database, and managed services like RDS, PlanetScale or a simple MySQL replica make it easy.

The catch is replication lag. A replica applies the primary's changes a little later, usually milliseconds, sometimes seconds under load. Laravel's existing 'sticky' => true option helps, but only within the same request: after a write, the rest of that request reads from the primary.

The classic Post/Redirect/Get flow breaks that. The PUT request writes and redirects; the browser then sends a brand new GET request, which is not sticky any more and happily reads from a replica that hasn't caught up. The user sees their old data and assumes the save failed.

The PreventStaleDatabaseReads middleware

The new middleware, added in 13.38, extends stickiness across requests for the user who wrote. When a request writes to the default connection, it stores a timestamp in the session. For the next couple of seconds (two by default), that user's reads go to the write connection, then return to the replicas.

To enable it for every web route, append it to the web group in bootstrap/app.php:

use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;

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

If your replicas lag more than two seconds at peak, set a longer window on the routes that matter with for(), which takes a number of seconds:

Route::middleware(['auth', PreventStaleDatabaseReads::for(10)])->group(function () {
    Route::get('/account', [AccountController::class, 'edit']);
    Route::put('/account', [AccountController::class, 'update']);
});

Pick a value at least as long as the worst lag you actually observe. Measure it rather than guessing: on MySQL, SHOW REPLICA STATUS reports how far behind a replica is.

DB::usingWritePdo() for a block of reads

Before 13.38, the per-query escape hatch was onWriteConnection(). Its limit is that it applies only to the query it starts. Load a relationship afterwards and that query goes back to a replica.

DB::usingWritePdo() takes a callback and sends every read inside it to the write connection, then restores the previous setting, even if the callback throws. It is ideal right after a job or an API call that just wrote data you need to read back consistently:

use Illuminate\Support\Facades\DB;

$order = DB::usingWritePdo(function () use ($orderId) {
    return Order::with('lines', 'payments')->findOrFail($orderId);
});

Inside DB::transaction() you don't need it: transactions already run on the write connection.

Limitations to know before you enable it

  • It needs a session. Stateless API routes using tokens won't benefit; for those, use usingWritePdo() or return the updated resource in the write response.
  • It only tracks the default connection. If you write to a second connection, it isn't covered.
  • It only protects the user who wrote. Other users can still read slightly older data from a replica. That is usually fine; if it isn't, the data probably shouldn't come from a replica at all.
  • The window must cover your lag. If replicas fall behind by five seconds and the window is two, users will still see stale data.
  • Tests: RefreshDatabase wraps each test in a transaction, which always uses the write connection. To test this behaviour, use DatabaseMigrations or DatabaseTruncation.

How I'm rolling it out

  1. Upgrade to 13.38 or later (composer update laravel/framework) and check php artisan --version. If you are still on Laravel 12, see my guide to upgrading Laravel 12 to 13 first.
  2. Keep sticky on. The middleware complements it; it doesn't replace it.
  3. Enable the middleware on the web group with the default window, and longer windows on admin or back-office routes where people edit and reload all day.
  4. Replace ad-hoc onWriteConnection() calls that load relationships with DB::usingWritePdo().
  5. Watch the primary's load for a few days. Sending a few seconds of reads per writing user back to the primary is cheap for most apps, but a write-heavy app with long windows can shift noticeable traffic.

Conclusion

Stale reads after a save are one of those bugs that make an app feel broken while every log looks fine. With Laravel 13.38, fixing them for a Laravel read replica setup is a one-line middleware plus DB::usingWritePdo() for the tricky spots. Upgrade, add PreventStaleDatabaseReads to the web group, size the window to your real replica lag, and you can finally close that "my changes didn't save" ticket for good.