News

Laravel Read Replicas: Prevent Stale Reads After a Write

Published
Laravel Read Replicas: Prevent Stale Reads After a Write image

Some Laravel applications send writes to one database server and reads to one or more replicas. A replica copies changes from the write server, but it can fall behind. When the lag is long enough, a user can change the title of a post, reload the page, and see the old title.

Laravel 13.38 adds a PreventStaleDatabaseReads middleware that sends a user's reads to the write server for a short time after they write, and a usingWritePdo() method for single blocks of code that need fresh data.

Sticky Connections and Redirects

A read/write connection in config/database.php has a sticky option:

'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => ['10.0.0.11', '10.0.0.12'],
],
'write' => [
'host' => ['10.0.0.10'],
],
'sticky' => true,
// ...
],

With sticky enabled, a write sends the reads that follow it to the write server, but only for the rest of that request. A form that submits with a PUT and redirects to a GET is two requests. The redirect starts with a fresh connection, so the GET reads from a replica again, which might not have the change yet.

Adding the Middleware

The PreventStaleDatabaseReads middleware is what you want. 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,
]);
})

You can also configure certain routes to use it if you want to experiment first or have a select number of controllers where you want this behavior:

use App\Http\Controllers\PostController;
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
 
Route::middleware(['auth', PreventStaleDatabaseReads::class])->group(function () {
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::get('/posts/{post}/edit', [PostController::class, 'edit']);
Route::put('/posts/{post}', [PostController::class, 'update']);
});

The window is two seconds by default, but you can configure it by passing a number of seconds to for() method:

Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
// ...
});

Two seconds covers a team whose lag stays under a second. Set the window to the longest lag you see in your own replica metrics, since a window shorter than the lag lets the old data through.

How It Works

The middleware runs on each request in three steps:

  1. Before the controller runs, it reads a _database_modified_at timestamp from the session. If that timestamp is inside the window, it calls useWriteConnectionWhenReading() on the default connection, so every select in the request uses the write server.
  2. The request runs as normal.
  3. After the response is built, it calls hasModifiedRecords() on the connection. If the request inserted rows, or updated or deleted at least one row, it saves the current timestamp to the session.

The usingWritePdo() Method for a Block of Code

Some code paths need fresh data no matter which user made the change, such as a job that runs right after a webhook updates a row. Laravel already has onWriteConnection(), but it only applies to the query it starts:

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

The posts() relationship builds a new query, and that query does not inherit the write connection. If the replica is behind, $drafts can come back empty even though the posts exist.

The usingWritePdo() method sends every read inside the callback to the write server:

use Illuminate\Support\Facades\DB;
 
$drafts = DB::usingWritePdo(function () use ($email) {
$user = User::query()->where('email', $email)->first();
 
return $user?->posts()->whereNull('published_at')->get();
});

The method returns whatever the callback returns. When the callback finishes or throws, the connection goes back to its previous setting. If reads were already forced to the write server before the call, they stay that way afterwards.

Queries inside a database transaction already use the write server, so a block wrapped in DB::transaction() does not need usingWritePdo().

Testing Which Server a Query Used

Each entry in the query log has a readWriteType key set to read or write. The framework's own tests for the middleware use it. The test below saves a post, requests the post page, and checks which server ran the first query:

use Illuminate\Support\Facades\DB;
 
public function test_reads_after_an_update_use_the_write_connection(): 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::enableQueryLog();
 
$this->actingAs($user)->get("/posts/{$post->id}")->assertOk();
 
$this->assertSame('write', DB::getQueryLog()[0]['readWriteType']);
}

Note: use DatabaseMigrations or DatabaseTruncation for this test instead of RefreshDatabase. The RefreshDatabase trait wraps each test in a transaction, and every query in a transaction uses the write server, so the assertion would pass even without the middleware.

Further Reading

Jack Bayliss contributed PreventStaleDatabaseReads in #62078 and usingWritePdo() in #62099.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Filed in

Sponsored

laravelcloud logo
Laravel Cloud

Easily create and manage your servers and deploy your Laravel applications in seconds.

Visit Laravel Cloud

The latest

View all →
Decide with Jev: Turn AI Answers into Decisions with PHP Rules image

Decide with Jev: Turn AI Answers into Decisions with PHP Rules

Read article
Skuul: Open-Source School Management Built on Laravel image

Skuul: Open-Source School Management Built on Laravel

Read article
Screenshot: Capture Images and PDFs with PHP and Laravel image

Screenshot: Capture Images and PDFs with PHP and Laravel

Read article
Reset Sticky Database Connections Between Laravel Queue Jobs image

Reset Sticky Database Connections Between Laravel Queue Jobs

Read article
PHP Security Releases Fix Two Critical RCE Vulnerabilities image

PHP Security Releases Fix Two Critical RCE Vulnerabilities

Read article
Prevent Stale Database Reads in Laravel 13.38 image

Prevent Stale Database Reads in Laravel 13.38

Read article