Laravel 13.38: no more stale reads after a write, usingWritePdo and a cache-safe /up

Laravel 13.38 adds PreventStaleDatabaseReads, Connection::usingWritePdo(), CountCrashesAsExceptions for listeners/mail/notifications and a route-cache-safe /up.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-13-38-stale-reads-write-pdo-health.md
Laravel 13.38 adds PreventStaleDatabaseReads, Connection::usingWritePdo(), CountCrashesAsExceptions for listeners/mail/notifications and a route-cache-safe /up.
Show the diff. Do not commit.
On this page · 13 sections
  1. What’s new at a glance
  2. Stop showing users stale data after a write
  3. usingWritePdo: force the writer for a whole callback
  4. Count worker crashes on listeners, mailables and notifications
  5. UniqueJobSkipped for unique broadcast events
  6. Floats in Rule::unique()->where()
  7. A /up health route that survives route:cache
  8. Notable fixes
  9. Upgrade notes
  10. Recap
  11. FAQ
  12. Next steps
  13. Sources

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 PreventStaleDatabaseReads to the web group 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 fire UniqueJobSkipped; /up is now an invokable HealthCheck class, so cached routes work after you move the app.

What you'll learn

  • PreventStaleDatabaseReads: read-your-own-writes across requests
  • DB::connection()->usingWritePdo() vs onWriteConnection()
  • #[CountCrashesAsExceptions] on listeners, mailables and notifications
  • UniqueJobSkipped for unique broadcasts, float Rule::unique()->where(), cache-safe /up

Who it's for: Laravel 13 developers using read replicas, queues, broadcasting or route caching

Tested with: Laravel 13.38.0 · PHP 8.4 · Pest 4 (7 tests / 17 assertions: stale-read middleware on a sqlite read/write connection, usingWritePdo restore on throw, CountCrashesAsExceptions on SendQueuedMailable, UniqueJobSkipped for a unique broadcast, float unique where, /up route serialization, date rule on an array)

What’s new at a glance#

ChangePRWhat it replaces
PreventStaleDatabaseReads middleware#62078A home-made “sticky for a few seconds” middleware
Connection::usingWritePdo()#62099Toggling useWriteConnectionWhenReading() on and off by hand
CountCrashesAsExceptions on listeners, mail, notifications#62048The attribute being silently ignored outside jobs
UniqueJobSkipped for unique broadcasts#62077“Where did my broadcast go?”
Floats in Rule::unique()->where()#62059A PHPStan error on where('price', 9.99)
/up as HealthCheck class#62049FileNotFoundException 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:

app/Http/Middleware/StickToWriter.phpphp
// 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;
}
bootstrap/app.phpphp
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:

routes/web.phpphp
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:

RequestConnection used for reads
GET with no recent writeread
POST that writes, then GETwrite
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():

app/Actions/LoadDrafts.phpphp
$drafts = User::onWriteConnection()
    ->where('email', $email)
    ->first()
    ?->posts()            // ⚠️ back on the replica
    ->whereNull('published_at')
    ->get();
app/Actions/LoadDrafts.phpphp
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.

app/Listeners/ExportOrders.phpphp
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.

app/Providers/AppServiceProvider.phpphp
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:

app/Http/Requests/StoreProductRequest.phpphp
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 a TypeError when 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 NotificationFailed flag 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/framework and you are on 13.38. No breaking changes, no config to publish.
  • Re-run php artisan optimize (or route:cache) so the cached /up route points at the new HealthCheck class.
  • 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

tests/Feature/WritePdoTest.phpphp
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#

  • PreventStaleDatabaseReads gives 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 /up works 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#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.