Reading the Laravel Session Without Saving It
Laravel saves the whole session at the end of every web request, even when nothing changed. Here is what that means when two requests overlap, and how to let a request read the session without writing it back.
A web app has no memory of its own between one page and the next. Every click arrives at the server as a fresh request, and the server only knows who you are and what you were doing because it keeps a small record for your browser, called the session. In a Laravel app, that record lives wherever you configure it: a file, Redis, a cookie, or a row in a database table.
Most of the time you can treat the session like a notebook that is always up to date. You write a value on one page, and the next page reads it. This post is about the one situation where that picture breaks, which is when two requests from the same browser run at the same time, and about a small middleware that fixed it in Akluma, the savings app I build with Laravel.
Laravel runs your requests side by side
The Laravel documentation says this plainly, in its section on session blocking:
By default, Laravel allows requests using the same session to execute concurrently. […] session data loss can occur in a small subset of applications that make concurrent requests to two different application endpoints which both write data to the session.
Two requests from one browser can easily overlap. A page that fires a couple of background calls when it loads does it, and so does a link clicked while the previous page is still sending something. Neither request waits for the other, and both open the same session.
The key phrase is "both write data to the session". It sounds as if it only concerns requests that change something. It does not, and that is the part worth understanding.
Every web request writes the session
Every route in routes/web.php runs through Laravel's web middleware group, and one of the middleware in that group is StartSession. It loads your session when the request begins and saves it when the request ends. In the framework source (Laravel 13), the end of a request looks like this:
\(response = \)next($request);
\(this->storeCurrentUrl(\)request, $session);
\(this->addCookieToResponse(\)response, $session);
\(this->saveSession(\)request);
And the save itself, in Illuminate\Session\Store:
public function save()
{
$this->ageFlashData();
$this->prepareErrorBagForSerialization();
\(this->handler->write(\)this->getId(), $this->prepareForStorage(
\(this->serialization === 'json' ? json_encode(\)this->attributes) : serialize($this->attributes)
));
$this->started = false;
}
There is no check for whether anything changed. Whatever the session looked like when the request began, plus any changes the request made, is written back in full. So every request in the web group is a writer, including one that only reads.
Last write wins
Put the two facts together and you get a classic race. Picture two overlapping requests, A and B:
1. A opens the session (plan: monthly)
2. B opens the session (plan: monthly)
3. A removes the plan, saves (plan: none)
4. B finishes, saves its copy (plan: monthly)
Request A's change is gone. Request B did nothing wrong. It never touched the plan. It saved the copy it had loaded at step 2. Nothing fails, nothing is logged, and both requests return success. The data is quietly back to what it was.
Whether it happens depends on which request finishes last, which depends on network speed and server timing. That makes this kind of bug unusually slippery. The same steps can fail every time on one device and never on another.
What it looked like in Akluma
Akluma has a short wizard for creating a savings goal. On its third step you pick a saving plan, such as weekly or monthly, and the app keeps your choice in the session until you confirm on a summary page. If you go back from the summary to step 3, the app clears the chosen plan, so that you cannot save a plan you are no longer looking at.
On a computer, that worked. On a phone, going back to step 3 and then returning to the summary still let you save the old plan. Reading the session straight from the database at the moment it mattered showed why: while the screen showed step 3 with nothing selected, the stored session still held the plan.
The request putting it back was a small "heartbeat" the app sends from every page, which reports how long you spent on screen. It fires when a page opens and when it closes, so moving from the summary to step 3 sent a heartbeat at the same moment as step 3's own requests. On a slower phone connection the heartbeat tended to finish last, and its end-of-request save restored the plan that step 3 had just cleared. Blocking only the heartbeat on the phone made the problem disappear, and allowing it brought the problem straight back.
The heartbeat never needed to change the session at all. It only needed to know who you are.
The fix: read the session, then don't save it
Laravel offers no documented option for "this request may read the session but must not save it". It does provide the pieces. A session store can swap its storage handler at runtime, and the framework ships a handler, NullSessionHandler, whose write method does nothing. The fix is a middleware of one line:
class DoNotSaveSession
{
public function handle(Request \(request, Closure \)next): Response
{
$request->session()->setHandler(new NullSessionHandler);
return \(next(\)request);
}
}
It goes on the heartbeat route, after authentication:
Route::post('/activity/heartbeat', ActivityHeartbeatController::class)
->middleware(['auth', 'throttle:30,1', DoNotSaveSession::class]);
By the time this middleware runs, StartSession has already loaded the session into memory and the auth middleware has already identified the user, so everything that reads the session keeps working. When the request ends, the usual save happens as always, but it now goes to a handler that discards it. Swapping before $next rather than after also covers responses Laravel builds from exceptions, such as a validation error.
Two details deserve to be stated honestly. First, setHandler() and NullSessionHandler are public in Laravel 13 but not described in the documentation, so a future version could change them without notice. Second, there is one side effect: a request that does not save also does not extend the session's lifetime. For Akluma that does not matter, because sessions last 30 days and every normal page view still extends them.
Removing StartSession from the route with withoutMiddleware() might look simpler, but it does not work here: without the session, the auth middleware has no way to know who is calling.
A test that catches a race on every run
A race that depends on timing is hard to test, because you cannot rely on the timing. The test for this fix avoids the problem by staging the overlap deliberately. A temporary middleware added to the web group writes a different session payload after the request has done its work, but before StartSession saves. That is exactly the moment when an overlapping request would land. Shortened slightly, the test reads:
it('leaves a session change made by an overlapping request in place', function () {
$overlap = simulateOverlappingSessionWrite();
$this->actingAs(User::factory()->create())
->withSession(['pick_date_plan' => 'monthly'])
->postJson('/activity/heartbeat', ['seconds' => 30, 'source' => 'web'])
->assertNoContent();
expect(\(overlap->handler->read(\)overlap->sessionId))
->toBe('written by the overlapping request');
});
A second test runs the same staged overlap against an ordinary route and expects the overlapping write to be overwritten. That control proves the setup really reproduces the race, so the first test is not passing by accident. Together they also guard against a Laravel upgrade quietly changing the undocumented methods the fix relies on.
When to use ->block() instead
Laravel's documented answer to this problem is session blocking. You add ->block() to a route, and requests from the same session that hit blocked routes wait their turn instead of overlapping:
Route::post('/profile', function () {
// ...
})->block(\(lockSeconds = 10, \)waitSeconds = 10);
It needs a cache driver that supports atomic locks, such as database, redis or file, and it cannot be used with the cookie session driver. A request that waits longer than $waitSeconds fails with a LockTimeoutException.
The catch is that blocking only works between routes that are all marked. Akluma's heartbeat fires alongside every page in every flow, so protecting against it would have meant marking nearly every route and making ordinary page loads queue behind heartbeats.
A rough rule that falls out of this:
- If a request has no reason to change the session, stop it from saving. That removes the race for every flow at once.
- If two requests genuinely both need to change the session, and your pages send them together by design, mark both routes with
->block().
Checking it outside the tests
Because the bug depends on timing, a single passing run proves little. Before the fix, the same steps on the staging server created the unwanted savings goal in one run out of two. After the fix, three runs on the same phone and one each in the Android app and in Chrome on the live site all behaved correctly. A simpler check also held locally: sending a heartbeat by hand left the session's last_activity timestamp in the database exactly as it was, which shows the request no longer writes the session at all.
If you suspect this kind of bug in your own app, the signs to look for are a session value that you set and later find reverted, a bug that appears on phones but not on computers (or the reverse), and requests from the same browser that arrive within the same second. The quickest proof is to read the stored session at the moment the screen and the data disagree.
Further reading in the Laravel 13 documentation: HTTP Session, including session blocking and custom session drivers; Middleware, including the default middleware groups and excluding middleware; and atomic locks in the Cache documentation.