The Laravel team released v13.38.0 with a PreventStaleDatabaseReads middleware for applications that read from database replicas, a usingWritePdo() method that sends every read in a callback to the write connection, a health route that works with routes cached on a different machine, and more.

#PreventStaleDatabaseReads Middleware

Jack Bayliss added an opt-in middleware for applications with separate read and write database connections. A replica can fall behind the write server, so a user who saves a change and reloads the page can see the old data.

After a request writes to the default database connection, the middleware stores a timestamp in the session. For the next two seconds, that user's reads go to the write connection. After that, reads go back to the replicas. You can append it to the web group:

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

Or apply it to a group of routes with a longer window:

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

The middleware needs a session, and it only tracks the default connection. It only covers the user who made the write, so other users can still read older data from a replica.

See #62078 for more details.

#DB::usingWritePdo()

Jack Bayliss also added usingWritePdo() to the database connection. The existing onWriteConnection() 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 sends every read inside the callback to the write connection, then restores the previous setting, even if the callback throws:

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

See #62099 for more details.

#CountCrashesAsExceptions on Listeners, Mailables, and Notifications

Dennis Petersmann made the #[CountCrashesAsExceptions] attribute from Laravel 13.34 work on queued listeners, mailables, and notifications. Before this release, it only worked on jobs.

On the other three, a worker that crashed, for example from running out of memory, was not counted toward maxExceptions, so the listener or mail was retried with no limit when $tries was 0:

#[MaxExceptions(3), CountCrashesAsExceptions]
class ExportOrders implements ShouldQueue
{
    public $tries = 0;
 
    public function handle(OrdersImported $event)
    {
        // ...
    }
}

See #62048 for more details.

#Child Connection Attribute Overrides an Inherited Connection

Volodya Kurshudyan fixed models ignoring the #[Connection] attribute when a parent class sets the $connection property. Queries for the child model went to the parent's connection, with no error:

abstract class BaseModel extends Model
{
    protected $connection = 'mysql';
}
 
#[Connection('reporting')]
class SalesReport extends BaseModel {}
 
(new SalesReport)->getConnectionName(); // Now returns "reporting"

See #62065 for more details.

#Unique Job Fixes

Sumaia Aktar fixed unique jobs scheduled with Schedule::job() keeping their lock when the push to the queue fails. 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. Laravel 13.37 made the same change for dispatch() and queued listeners. See #62083.

Jack Bayliss also made broadcast events that implement ShouldBeUnique dispatch the UniqueJobSkipped event when they are skipped, the same way queued jobs do. See #62077.

#Lower Memory Use in lazy() and lazyById()

Volodya Kurshudyan changed lazy() and lazyById() to free the previous chunk before fetching the next one. Before this change, two chunks were held in memory at once, so peak memory was about twice what it needed to be. The chunk() and chunkById() methods already worked this way.

See #62086 for more details.

#Release Notes

You can see the complete list of new features and updates below and the diff between 13.37.0 and 13.38.0 on GitHub. The following release notes are directly from the changelog:

#v13.38.0