In a Laravel quote wizard, two concurrent requests on one session each save a step and one change disappears; why, and how does ->block() help?
answer
- read at start, whole payload written at end
- no session locking by default
- lock key session:{id} in the cache
- block(10, 10): lock and wait seconds
- LockTimeoutException after the wait
basics
~20 sLaravel loads the session when a request starts and writes the whole payload back at the end, unlocked, so the later of two overlapping requests overwrites the other's change. ->block() on the routes serialises them with a per-session cache lock.
solid answer
~40 sLaravel does not use PHP's native sessions or their file locking. The session middleware reads the payload when the request starts and writes the entire array back after the response is built. If an autosave of the drivers step and a submit of the coverage step overlap on one session, both start from the same snapshot, and whichever saves last erases the other's key. `Route::post(...)->block($lockSeconds = 10, $waitSeconds = 10)` makes the middleware acquire a cache lock named `session:{id}` before starting the session, hold it up to `$lockSeconds`, and have other blocked requests poll for up to `$waitSeconds`, then throw `LockTimeoutException`. Every route that writes the contested keys must be blocked; the cache store must support atomic locks, and the cookie driver cannot be used.
code
php · 13 lines<?php
use App\Http\Controllers\QuoteController;
use Illuminate\Support\Facades\Route;
Route::post('/quote/drivers/autosave', [QuoteController::class, 'autosaveDrivers'])
->block($lockSeconds = 10, $waitSeconds = 5);
Route::post('/quote/coverage', [QuoteController::class, 'storeCoverage'])
->block($lockSeconds = 10, $waitSeconds = 5);
// Both routes now take the cache lock "session:{id}" before loading the session,
// so their read-modify-write cycles run one after the other.go deeper
Know that two requests from the same browser can run at the same time and both change the session.
Explain the load, run, save cycle of the session middleware and why the whole-payload write makes the last saver win.
Diagnose intermittent lost session data, apply ->block() to every writer with sane timeouts, and know its cache and driver requirements.
Decide when multi-step state should leave the session for a database draft, trading simplicity for concurrency safety.
## The lost update Laravel's `StartSession` middleware handles a request in three steps: 1. **load**: read the session payload from the driver into memory; 2. **run**: pass the request to the rest of the stack and your controller; 3. **save**: age flash data and write the **entire** payload back to the driver. Nothing in that flow holds a lock across the request; the file handler only locks each individual read and write. The middleware's own comment notes that Laravel sessions do not use PHP's native sessions at all, so the request-long session lock a plain PHP developer may expect does not exist here. Now picture the insurance quote wizard. The drivers page autosaves through a background `POST /quote/drivers/autosave` while the user clicks "Continue", which posts `/quote/coverage`: | Time | Request A (autosave) | Request B (coverage) | |---|---|---| | t0 | loads `{step: 2}` | loads `{step: 2}` | | t1 | puts `quote.drivers` | puts `quote.coverage` | | t2 | saves `{step: 2, drivers}` | | | t3 | | saves `{step: 2, coverage}` | After t3 the stored session has no `drivers` key: B's payload, built from the t0 snapshot, replaced A's. The same happens on any driver, database, Redis or file, because each write replaces the whole payload. ## What block() does `Illuminate\Routing\Route::block($lockSeconds = 10, $waitSeconds = 10)` records two numbers on the route. When the session middleware sees them, it: - asks the **cache** for a lock named `session:` plus the session ID, held for at most `$lockSeconds`; - calls the lock's `block($waitSeconds)`, retrying every 50 milliseconds; - only after acquiring the lock, loads the session, runs the request and saves; - releases the lock in a `finally` block. If the lock cannot be acquired within `$waitSeconds`, `Illuminate\Contracts\Cache\LockTimeoutException` is thrown and the request fails. `withoutBlocking()` exists for routes that must opt out. ## The conditions that make it work 1. **Every writer must be blocked.** A request on an unblocked route never asks for the lock, so it can still interleave. In the wizard, mark both the autosave and the step routes. 2. **The cache store must support atomic locks.** The docs list `memcached`, `dynamodb`, `redis`, `mongodb`, `database`, `file` and `array`; Laravel 13's skeleton default `CACHE_STORE=database` qualifies. The lock lives in the default cache store, so on several servers that store must be shared. 3. **Not with the cookie driver.** The docs exclude it from session blocking. 4. **Choose honest timeouts.** `$lockSeconds` should exceed the slowest request on the route, or the lock can expire while it still runs; `$waitSeconds` bounds how long a queued request may wait before failing. ## Costs and alternatives Blocking turns parallel requests from one user into a queue, so a slow request delays every other blocked request on that session. Use it on the few routes that write the same keys, not on everything. Often better: - **keep valuable state out of the session**: store the quote as a draft row and update columns, so concurrent writes touch different fields; - **split keys by writer** and avoid read-modify-write on shared arrays such as `push()` on the same list from two endpoints; - **debounce the client** so autosave never overlaps a step submission. ## How to recognise the bug Symptoms are intermittent: a step's data "sometimes" vanishes, typically when a page fires several requests at once, or when a user double-clicks. Reproduce it by delaying one endpoint in a local build and firing both requests together; if the loss disappears after adding `->block()` to both routes, the race is confirmed. ## Summary for the interview - **Cause**: whole-payload, last-writer-wins saves with no request-long lock. - **Tool**: `->block($lockSeconds, $waitSeconds)` on every route that writes the contested keys, backed by an atomic-lock cache store. - **Failure mode**: `LockTimeoutException` when the wait expires. - **Better design**: move valuable wizard state out of the session, or make concurrent writers touch different data.
- Only /quote/coverage has ->block(); the autosave route does not. Is the lost update fixed?No. The autosave request never asks for the lock, so it can still load the session while the coverage request holds it, and its later save can overwrite the coverage change, or be overwritten. Session blocking only serialises requests on routes that call `block()`, so every route writing the contested keys needs it.
- What happens to a blocked request that cannot get the session lock in time?The lock's `block()` call gives up after the route's wait seconds, 10 by default, and throws `Illuminate\Contracts\Cache\LockTimeoutException`. The request then fails through the normal exception handling, so the client sees an error rather than a silently lost update.
Two clerks each photocopy the same form, fill in different boxes, and file their copy back; whichever files last erases the other's work. block() puts a single pen at the counter: a clerk must hold it before taking the form, and the others queue for a limited time. A clerk who ignores the pen can still clobber the form.
saying these in an interview costs you the question
- Laravel holds a session lock for the whole request, like native PHP
- Blocking one of the two routes is enough
- ->block() works with the cookie session driver
- The database driver merges concurrent session writes
- block() makes requests from different users wait for each other