Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Laravel 13.38: Stop Serving Your Users Stale Reads (2026)

You know the bug. A user updates their profile, hits save, the page reloads — and shows the old data. They refresh. Still old. Thirty seconds later it’s correct, and your support inbox has a new message: “your site is broken.”

If you read from database replicas, that bug has a name: replica lag. The write lands on the primary, the next read goes to a replica that hasn’t caught up yet, and your user sees a version of reality from ten seconds ago. Laravel 13.38 (tagged October 20, announced October 21, 2026 — and still the latest 13.x at the time of writing) finally treats this as a first-class framework problem. The headline is a new PreventStaleDatabaseReads middleware, plus a DB::usingWritePdo() escape hatch for when you need certainty in code.

This is your practical upgrade guide: what each feature does, copy-paste setup, the caveats that will bite you, and the rest of the 13.38 changelog worth knowing — plus the 13.37 highlights, since this blog skipped that release.

Laravel 13.38, in one breath

v13.38.0 is the week’s minor release on the 13.x line (Laravel 13 is the only major still receiving bug fixes). The release notes, covered in detail by Laravel News, lead with the stale-reads work, then stack up a run of reliability fixes: CountCrashesAsExceptions on queued listeners, mailables and notifications, a unique-job lock fix for Schedule::job(), halved peak memory in lazy() and lazyById(), and a quiet but nasty multi-connection bug fix. Everything is opt-in, and the upgrade is one command — covered at the end.

PreventStaleDatabaseReads: the headline feature

The standard read/write split in config/database.php gives you read and write hosts plus the sticky option, which keeps reads on the write connection within the current request after a write. The gap was always the next request: the user saves, the browser reloads, a brand-new request reads from a replica that’s a few seconds behind — stale page, confused user.

The new middleware closes exactly that gap. After a request performs a write on the default database connection, it stores a timestamp in the session. For the next two seconds, that user’s reads are routed to the write connection. After the window closes, reads go back to the replicas. It’s the framework-level version of the fix every team with replicas has hand-rolled at least once.

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,
    ]);
})

Or apply it to a route group with a longer window, where ::for(10) sets a ten-second window instead of the default two:

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

Three caveats before you ship it:

  1. It needs a session. The write timestamp lives in the session, so this helps routes that have one — the web group, not stateless API routes.
  2. It only tracks the default connection. If you write through a named connection, the middleware won’t see it. Most apps write through the default connection, but multi-database setups should know this.
  3. It covers the writer, not the world. Only the user who made the write gets pinned to the primary. Everyone else can still read slightly older data from a replica. That’s deliberate — you don’t want one user’s save to pin all traffic to the primary — but “globally consistent” remains your problem.

DB::usingWritePdo(): the escape hatch

The middleware solves the common case. But what about a queued job that imports an order and then needs to read it back? A console command that backfills and verifies? usingWritePdo(), added to the database connection, is the code-level version: every read inside the callback goes to the write connection, then the previous setting is restored — even if the callback throws.

It exists because the older onWriteConnection() wasn’t enough. That method only applies to the query it starts, so a relationship loaded from the result still reads from a replica:

// The posts query still runs on a replica
$posts = User::onWriteConnection()
    ->where('email', $email)
    ->first()
    ?->posts()
    ->whereNull('published_at')
    ->get();

The new method covers the whole block:

$posts = DB::usingWritePdo(fn () => User::query()
    ->where('email', $email)
    ->first()
    ?->posts()
    ->whereNull('published_at')
    ->get()
);

Reach for the middleware for the standard web flow, and for usingWritePdo() anywhere you need a guarantee inside a specific block of code — jobs, commands, and anywhere the session isn’t available.

Missed Laravel 13.37? Here’s the catch-up

This blog covered 13.36’s fluent validation builders but skipped 13.37, so here’s the part of that release worth knowing, all verified against the release notes:

  • Rule::numeric(strict: true) — the typed form of numeric:strict. String numbers fail, real ints and floats pass. Quick check in tinker:
    Validator::make(['n' => '42'], ['n' => Rule::numeric(strict: true)])->fails(); // true — the string failed
  • queue:failed --queue=… filters the failed-jobs list by queue, and queue:forget now accepts multiple IDs (php artisan queue:forget 1 2 3). Small, but if you run queues you’ll use both weekly.
  • JPEG XL support in the Image API — ->toJxl()->quality(…), the same shape as the existing HEIC/AVIF methods.
  • Unique lock released on failed dispatch — 13.37 fixed unique jobs keeping their lock when dispatch() or a queued listener failed to push. 13.38 extends the same fix to Schedule::job(), below.

The reliability fixes worth knowing

Worker crashes now count on listeners, mailables, and notifications

The #[CountCrashesAsExceptions] attribute arrived in 13.34 for jobs: a worker that crashes (say, from running out of memory) counts toward maxExceptions instead of retrying silently forever. 13.38 extends it to queued listeners, mailables, and notifications. Before this, a crashed worker processing one of those with $tries = 0 would retry with no limit:

#[MaxExceptions(3), CountCrashesAsExceptions]
class ExportOrders implements ShouldQueue
{
    public $tries = 0;

    public function handle(OrdersImported $event)
    {
        // ...
    }
}

If you use unlimited retries anywhere, audit those classes after upgrading.

Schedule::job() no longer keeps a dead lock

This one is a genuine footgun fix. A unique job scheduled with Schedule::job() kept its lock when the push to the queue failed. With the default uniqueFor of 0, the lock never expired — so the scheduler skipped the job on every later run, even after the queue came back. The scheduler now releases the lock and rethrows the exception. Related: broadcast events implementing ShouldBeUnique now dispatch the UniqueJobSkipped event when they’re skipped, the same way queued jobs already do.

lazy() and lazyById() use half the memory

lazy() and lazyById() now free the previous chunk before fetching the next one. Before, two chunks were held in memory at once, so peak memory was roughly double what it needed to be. chunk() and chunkById() already worked this way. If you process large tables with lazy(), this is a free memory cut — no code changes needed. (Our queue workers guide covers worker memory limits if you want the full picture.)

Child #[Connection] attributes are respected again

A quiet bug: when a parent model set the $connection property, a child model’s #[Connection] attribute was silently ignored — queries went to the parent’s connection with no error. Now the attribute wins:

abstract class BaseModel extends Model
{
    protected $connection = 'mysql';
}

#[Connection('reporting')]
class SalesReport extends BaseModel {}

(new SalesReport)->getConnectionName(); // Now returns "reporting"

If you run reporting models against a separate connection, double-check that your queries are going where you think — they might have been hitting the wrong database all along.

Also in 13.38

  • Health route works with route caching across machines — relevant if your deploy builds the route cache on a different machine than the one serving traffic.
  • Float values allowed in the Unique rule’s where — one less validation edge case.
  • TypeError in date comparison rules on non-date values fixed — bad input now fails validation instead of throwing.

Upgrade in one command

composer update laravel/framework

Everything above is opt-in except the bug fixes, which are strictly improvements: the unique-lock release, the memory change, and the #[Connection] fix all match what the APIs already promised. The one thing to do deliberately is the middleware — decide whether your app reads from replicas, and if so, append it to the web group and stop hand-rolling your own stale-read workaround.

Written 22 October 2026 against Laravel 13.38.0, the latest 13.x release at the time of writing.

Further Reading & References

Leave a Comment