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:
- Before the controller runs, it reads a
_database_modified_attimestamp from the session. If that timestamp is inside the window, it callsuseWriteConnectionWhenReading()on the default connection, so everyselectin the request uses the write server. - The request runs as normal.
- 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
- Prevent Stale Database Reads in Laravel 13.38
- Reset Sticky Database Connections Between Laravel Queue Jobs
- Run Large PlanetScale Queries in Laravel With DB::olap()
- Stateful Scaling: Laravel Session Management in Kubernetes Clusters
Jack Bayliss contributed PreventStaleDatabaseReads in #62078 and usingWritePdo() in #62099.