Caption: The class is missing on v13.37.0. It landed in 13.38.0 on 20 Oct 2026. Packagist this morning is 13.39.0, and the middleware file is unchanged.
Introduction
I keep a throwaway Laravel 13 app for framework bumps. The screen I care about this week is a post editor: PUT /posts/9, then a redirect to GET /posts/9. The write host has the new title. The show page still paints the old one, until I refresh again.
That is replica lag, not a lost update. sticky => true in config/database.php only lasts the request that wrote. The redirect is a new request and a new connection, so the GET goes back to the read host.
Laravel 13.38.0 shipped 20 Oct 2026. Laravel News covered it 21 Oct 2026 and again 23 Oct 2026. I am writing this on 28 Oct 2026 (2026-W44). The queue row is 2026-W43, and nothing was tagged 2026-W44, so this is the lowest undone priority. The latest 13.x on Packagist this morning is v13.39.0 (27 Oct 2026). I read the middleware, Connection, DatabaseManager, SortedMiddleware, and the default priority list on tag v13.39.0. The middleware file matches v13.38.0. I did not boot the throwaway app.
About the feature
The 13.x database docs, retrieved 28 Oct 2026, already document both: Preventing Stale Database Reads, and Using the Write Connection for Reads.
sticky makes getReadPdo() return the write PDO when recordsModified is set. That flag dies with the connection. The redirect is a new one.
Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads covers the next request, and only for the session that wrote. handle($request, $next, $seconds = 2) loads the default connection with $this->manager->connection(). It calls useWriteConnectionWhenReading() when session key _database_modified_at is greater than or equal to Date::now()->subSeconds($seconds)->getTimestamp(). After $next returns, a true hasModifiedRecords() stores Date::now()->getTimestamp() under that key. There is no terminate() method. If the action updates the row and then throws, the put() never runs, and the following GET still reads the replica.
for($seconds) returns the class, a colon, and the seconds. The docs pass for(5). A timestamp from exactly two seconds ago still matches the default, because the check is >=.
update() goes through affectingStatement(), which marks the connection only when rowCount() > 0. Saving the same title does not refresh the session key. Selects never mark it. insert() does, via statement().
Connection::usingWritePdo() is the other 13.38 API. It remembers readOnWriteConnection, turns the flag on, runs the closure, and restores the old value in finally, including when the closure throws. If the flag was already on, DatabaseConnectionTest shows it stays on. The closure receives the connection. The docs pass that argument. The 21 Oct article calls DB::usingWritePdo() and ignores it. DatabaseManager::__call() forwards unknown methods to connection(), so both hit the default connection.
Model::onWriteConnection() returns static::query()->useWritePdo(). That boolean sits on one builder. $user->posts() builds another. The posts select uses the read host.
Caption: The PUT stores _database_modified_at when a row changes. The next GET in that session uses the write host inside the window. A later GET, and anyone else's GET, use the replica.
Why I picked it
The editor already does PUT then redirect(). I have had sticky on for the same-request read-after-write. It does nothing for the show page. Two seconds is the default window. If the replica's lag chart sits higher than that, for() is the knob, not a second copy of the middleware.
Where it can be used
I would put it on web routes that write and then render the same row: the edit form, a status toggle, a profile save. It needs a session. Request::session() throws RuntimeException with Session store not set on request. The api group never starts one, so I would not append the class there.
It watches the default connection only. A model pinned to mysql_reporting is invisible to the session key. Other sessions keep reading replicas. The docs say that on purpose.
usingWritePdo() is what I want in a job that must see a row some other process just wrote. Inside DB::transaction(), getReadPdo() already returns the write PDO while transactions > 0, so I do not nest the helper there. The same 13.38 tag also extended #[CountCrashesAsExceptions] to queued listeners, mailables, and notifications, and let a child #[Connection] beat an inherited $connection. Neither one is this lab.
Benefit
Both APIs live in laravel/framework. v13.38.0 and v13.39.0 composer.json both require php: ^8.3 and replace illuminate/database with self.version. I do not composer require illuminate/database.
Caption: One package. PreventStaleDatabaseReads only watches DatabaseManager::connection() with no name. DB::connection()->usingWritePdo() is the form in the docs. DB::usingWritePdo() reaches the same method through __call.
Practical example from a recent application
Lab shape from the queue notes and tag v13.39.0. Confirm composer show laravel/framework is 13.38.0 or newer before appending the middleware. I would bump the throwaway app to 13.39.0, which is the current 13.x.
composer show laravel/framework | sed -n '1,8p'
# name : laravel/framework
# versions : * v13.39.0 # Packagist 27 Oct 2026. These APIs landed in 13.38.0.
If that line still says 13.37.x, stop. The class is missing and usingWritePdo is not on Connection. Split mysql the way the docs do: read.host, write.host, and 'sticky' => true. Sticky still covers selects later in the PUT. The middleware is for the redirect.
The docs and the 23 Oct article stop at web(append:). On tag v13.39.0 the web group already ends with SubstituteBindings, so append sits after it. SortedMiddleware only moves classes on the priority list. auth implements AuthenticatesRequests, so the sorter pulls auth in front of SubstituteBindings and leaves this class last: StartSession, auth, SubstituteBindings, PreventStaleDatabaseReads, controller. The user and the {post} binding have already read the replica. The 23 Oct assertion on DB::getQueryLog()[0]['readWriteType'] is that early select, still read.
prependToPriorityList before AuthenticatesRequests puts the class after StartSession and before auth. for(10) is the string PreventStaleDatabaseReads:10. The sorter strips the colon, so the priority entry still matches.
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
$middleware->prependToPriorityList(
before: AuthenticatesRequests::class,
prepend: PreventStaleDatabaseReads::class,
);
});
Caption: Left: append only. auth and SubstituteBindings select before the flag. Right: the class is on the priority list before auth.
Do not use RefreshDatabase here. It wraps the test in a transaction, getReadPdo() returns the write PDO while transactions > 0, and the assertion passes with the middleware commented out. DatabaseMigrations or DatabaseTruncation is the lab. DB::forgetRecordModificationState() after the factories, or the create() already looks like the write I meant to measure.
use Illuminate\Foundation\Testing\DatabaseTruncation;
use Illuminate\Support\Facades\DB;
public function test_show_after_update_reads_the_write_host(): 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::flushQueryLog();
DB::enableQueryLog();
$this->actingAs($user)->get("/posts/{$post->id}")->assertOk();
$show = collect(DB::getQueryLog())->first(
fn (array $entry) => str_contains($entry['query'], 'posts')
&& str_starts_with(strtolower($entry['query']), 'select')
);
$this->assertSame('write', $show['readWriteType'] ?? null);
}
The log key is readWriteType. The values are read, write, or direct. I match the posts select, not index 0.
Failure I hit if I only call onWriteConnection(). The user row uses the write PDO. The drafts relation does not:
$user = User::onWriteConnection()->where('email', $email)->first();
// posts() is a new builder. This select still uses the read host.
$drafts = $user?->posts()->whereNull('published_at')->get();
Fix for that block, including when the callback throws:
$drafts = DB::connection()->usingWritePdo(function ($connection) use ($email) {
$user = User::query()->where('email', $email)->first();
return $user?->posts()->whereNull('published_at')->get();
});
User::query() and posts() share that connection while the flag is on. Afterwards getReadPdo() is whatever it was before. A model on another $connection is outside the callback. No CVE ships with this middleware. The token-guard advisory from 20 Oct 2026 (GHSA-f9x3-7vq9-cph6) is already fixed in 13.29.0. A 13.37 app has that fix. It does not have this class.
Conclusion
What I would keep: laravel/framework at 13.39.0, sticky still true for the request that wrote, PreventStaleDatabaseReads on the web group and on the priority list before AuthenticatesRequests, and DB::connection()->usingWritePdo() when a relation has to see a write from outside this request. The redirect GET for that session then reads the write host for the window I set. Another user's GET stays on the replica. Next I would set for() from the lag chart, not from a number I copied.
Did you hit the same wall?
I got stuck on a PUT that redirects to GET and paints the old title, and then on web(append:) leaving SubstituteBindings ahead of the middleware so the bound model was still the replica. Did you hit the same thing — a two-second window that is shorter than the lag, Session store not set on request. after appending this to api, a RefreshDatabase test that always says write, or a posts() relation that ignored onWriteConnection()? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan
References
- Laravel 13.x database docs, retrieved 28 Oct 2026 (
sticky, Preventing Stale Database Reads,DB::connection()->usingWritePdo()): https://laravel.com/docs/13.x/database - Laravel News, 21 Oct 2026, “Prevent Stale Database Reads in Laravel 13.38”: https://laravel-news.com/laravel-13-38-0
- Laravel News, 23 Oct 2026, “Laravel Read Replicas: Prevent Stale Reads After a Write”: https://laravel-news.com/laravel-prevent-stale-database-reads
- PRs #62078 (
PreventStaleDatabaseReads), #62099 (usingWritePdo), #62048 (crash attribute on listeners, mailables, notifications), #62065 (child#[Connection]) - Tag
v13.39.0:PreventStaleDatabaseReads.php,Connection.php,DatabaseManager.php,SortedMiddleware.php,Http\Kernel$middlewarePriority,tests/Database/DatabaseConnectionTest.php. Middleware bytes matchv13.38.0. The class is absent onv13.37.0. - Packagist
laravel/frameworkv13.38.0, time 20 Oct 2026 10:45 UTC, published-time 10:46 UTC, GitHub release 10:47 UTC. v13.39.0 time 27 Oct 2026 14:04 UTC, published-time 14:05 UTC, GitHub release 14:06 UTC (latest 13.x at write time): https://packagist.org/packages/laravel/framework - v13.38.0 and v13.39.0
composer.json:php: ^8.3,replace.illuminate/database: self.version