Your user renames a post, hits refresh, and sees the old title. Nothing is broken: the read replica is just two seconds behind. They file a bug anyway. Laravel 13.38 ships the fix as one line of middleware, plus a scoped way to force the writer and a /up route that finally survives route:cache on another machine.
TL;DR
- 13.38.0 was released on October 20, 2026 (10:47 UTC). Every new feature is opt-in.
- Append
PreventStaleDatabaseReadsto thewebgroup and a user’s reads hit the write connection for 2 seconds (or::for(5)) after they wrote something. DB::connection()->usingWritePdo(fn () => …)forces the writer for everything in the callback, relations included, and restores the old state even if it throws.#[CountCrashesAsExceptions]now works on queued listeners, mailables and notifications; unique broadcasts fireUniqueJobSkipped;/upis now an invokableHealthCheckclass, so cached routes work after you move the app.
What’s new at a glance#
| Change | PR | What it replaces |
|---|---|---|
PreventStaleDatabaseReads middleware | #62078 | A home-made “sticky for a few seconds” middleware |
Connection::usingWritePdo() | #62099 | Toggling useWriteConnectionWhenReading() on and off by hand |
CountCrashesAsExceptions on listeners, mail, notifications | #62048 | The attribute being silently ignored outside jobs |
UniqueJobSkipped for unique broadcasts | #62077 | “Where did my broadcast go?” |
Floats in Rule::unique()->where() | #62059 | A PHPStan error on where('price', 9.99) |
/up as HealthCheck class | #62049 | FileNotFoundException on /up after route:cache in CI |
Stop showing users stale data after a write#
The sticky option in config/database.php only lasts for the current request. The next request goes back to the replica, which may not have the row yet. PreventStaleDatabaseReads carries that stickiness across requests through the session (docs).
Laravel 13.37, then Laravel 13.38:
// A custom middleware you had to write and maintain
public function handle(Request $request, Closure $next)
{
$db = DB::connection();
$db->useWriteConnectionWhenReading(
$request->session()->get('wrote_at', 0) >= now()->subSeconds(2)->timestamp
);
$response = $next($request);
if ($db->hasModifiedRecords()) {
$request->session()->put('wrote_at', now()->timestamp);
}
return $response;
}use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})Need a longer window because your replica lag spikes? Pass seconds with for(), which simply builds the Class:5 middleware string:
use App\Http\Controllers\PostController;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
Route::middleware(PreventStaleDatabaseReads::for(5))->group(function () {
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::put('/posts/{post}', [PostController::class, 'update']);
});With Inertia, this is exactly the PUT → redirect → GET round trip your Vue page makes after form.put(). The redirected GET now reads from the writer, so the page shows the new title instead of the old one.
We checked it on 13.38 with a sqlite connection that has separate read and write configs:
| Request | Connection used for reads |
|---|---|
GET with no recent write | read |
POST that writes, then GET | write |
Same GET 3 seconds later (default 2s window) | read |
Gotcha: Session only, default connection only
It must run after StartSession (the web group already does that), so it is not for stateless APIs. It only protects the user who wrote the data, on the default connection. Other users can still see replica lag.
usingWritePdo: force the writer for a whole callback#
User::onWriteConnection() only applies to that one query. Chain ->posts() after it and the relation query quietly goes back to the replica. usingWritePdo() scopes the writer to everything inside the callback (docs).
onWriteConnection(), then usingWritePdo():
$drafts = User::onWriteConnection()
->where('email', $email)
->first()
?->posts() // ⚠️ back on the replica
->whereNull('published_at')
->get();use Illuminate\Support\Facades\DB;
$drafts = DB::connection()->usingWritePdo(fn () => User::query()
->where('email', $email)
->first()
?->posts() // ✅ also on the writer
->whereNull('published_at')
->get());It returns whatever the callback returns, and puts the previous read preference back in a finally block. We threw an exception inside the callback in our lab: the read PDO was the replica again afterwards.
Count worker crashes on listeners, mailables and notifications#
Laravel already lets you mark a job with #[CountCrashesAsExceptions] so an out-of-memory kill counts towards maxExceptions (docs). On a queued listener, mailable or notification it was silently ignored, so a listener that crashed every time was retried forever. 13.38 copies the flag onto the wrapper jobs.
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Attributes\CountCrashesAsExceptions;
use Illuminate\Queue\Attributes\MaxExceptions;
#[MaxExceptions(3), CountCrashesAsExceptions] // now respected here too
class ExportOrders implements ShouldQueue
{
public function handle(OrdersImported $event): void
{
// the export that eats all the memory
}
}UniqueJobSkipped for unique broadcast events#
A broadcast event that implements ShouldBeUnique is dropped while its lock is held. That was invisible. Now Laravel fires Illuminate\Queue\Events\UniqueJobSkipped with the UniqueBroadcastEvent as $event->job, the same event queued jobs already fire.
use Illuminate\Broadcasting\UniqueBroadcastEvent;
use Illuminate\Queue\Events\UniqueJobSkipped;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
Event::listen(function (UniqueJobSkipped $e) {
if ($e->job instanceof UniqueBroadcastEvent) {
Log::info('Broadcast skipped', ['event' => $e->job->event::class]);
}
});Handy if your Vue page uses Echo and you are wondering why one PriceUpdated never arrived.
Floats in Rule::unique()->where()#
This always worked at runtime, but the docblock said int|string|…, so PHPStan complained. The type now includes float:
Rule::unique('products', 'sku')->where('price', 9.99);
// unique:products,sku,NULL,id,price,"9.99"A /up health route that survives route:cache#
withRouting(health: '/up') used to register a closure that loaded its view via __DIR__. When you run php artisan optimize in CI or a Docker build stage, the closure is serialized with the build machine’s absolute path. Deploy to another path and every /up returns a FileNotFoundException, which is a fun way to fail a load-balancer health check.
The route now points to Illuminate\Foundation\Http\HealthCheck@__invoke, so __DIR__ is resolved on the server that answers. Same responses, same DiagnosingHealth event. You do not need to change anything; just re-run route:cache after upgrading.
Tip: Multi-stage Docker builds
If you dropped health: or skipped route:cache to work around this, you can put them back on 13.38.
Notable fixes#
- Date rules (
after,before, …) no longer throw aTypeErrorwhen the value is not a date, for example an array. They just fail validation (#62103). - Unique scheduled jobs release their lock if dispatching fails, so the next run is not blocked (#62083).
lazy()/lazyById()free the previous chunk, lowering peak memory on big exports (#62086).- Eloquent:
whereBetween()with collection bounds (#62062),clone()on relations (#62081),ofMany()aliases in closures (#62051), self-referencing one-of-many aggregates (#62043), and a child#[Connection]attribute now beats an inherited$connection(#62065). - Collections:
mode()no longer truncates floats (#62063). - Notifications: the
NotificationFailedflag is reset before each channel (#62074). - SQLite: altering a table with a foreign key to a table name that needs quoting (#62084).
- Laravel Cloud logging formatter restored (#62068).
Upgrade notes#
composer update laravel/frameworkand you are on 13.38. No breaking changes, no config to publish.- Re-run
php artisan optimize(orroute:cache) so the cached/uproute points at the newHealthCheckclass. - Check the #62065 fix if a child model uses
#[Connection]while a parent sets$connection: the child’s attribute now wins, which may change which database it queries.
See usingWritePdo restore the replica
use Illuminate\Support\Facades\DB;
it('scopes the writer to the callback', function () {
config()->set('database.connections.rw', [
'driver' => 'sqlite', 'database' => ':memory:',
'read' => ['database' => ':memory:'], 'write' => ['database' => ':memory:'],
]);
$c = DB::connection('rw');
expect($c->usingWritePdo(fn ($db) => $db->getReadPdo() === $db->getPdo()))->toBeTrue()
->and($c->getReadPdo())->not->toBe($c->getPdo());
});Recap#
PreventStaleDatabaseReadsgives each user read-your-own-writes for 2 seconds (or::for(n)) across requests.usingWritePdo()forces the writer for a callback, relations included, and always restores state.#[CountCrashesAsExceptions]finally works on queued listeners, mailables and notifications.- Unique broadcasts fire
UniqueJobSkipped,Rule::unique()->where()accepts floats, and/upworks with cached routes anywhere.
FAQ#
Do I need PreventStaleDatabaseReads without read replicas?
No. With a single database connection there is no replica lag, so the middleware has nothing to fix.
Is PreventStaleDatabaseReads the same as the sticky option?
sticky only covers the current request. The middleware stores the last write time in the session and keeps the next requests on the writer for a few seconds.
Can I use it on API routes?
Only if they have a session. It needs StartSession to run first, so token-based stateless APIs are not covered.
Does usingWritePdo affect other connections?
No. It only changes the connection you call it on, for the duration of the callback.
Do I have to change my health route?
No. withRouting(health: '/up') keeps working; just rebuild your route cache after upgrading.
Next steps#
- Laravel 13.37: strict Rule::numeric(), queue filters and JPEG XL
- Laravel 13.36: validation builders, OLAP and sticky queues
- Read and write connections in the Laravel docs



