Two requests updated the same row eleven milliseconds apart. Both read a wallet balance of 500. Both subtracted 200 and wrote 300 back. The customer had spent 400, but the database only ever recorded losing 200 once, because the second write didn’t know about the first — it had already read its starting value before the first write landed.
Nobody’s code was wrong, in isolation. $wallet->balance -= $amount; $wallet->save(); is correct, obviously correct, the kind of line you write without a second thought. It is also a race condition the moment two of those requests can be in flight at once, and in a Laravel app that eventually does any real traffic, they will be.
A transaction is not a lock. This is the part that trips up engineers who’ve been told “wrap it in DB::transaction and you’re safe.” A transaction guarantees your own writes are atomic and isolated from being partially visible. It does not, by itself, stop two transactions from both reading the same stale value before either one commits. You need to actually tell the database to serialize access to that row, and most Laravel code never does, because it’s never had to — until the exact day traffic makes it matter, at which point it’s a support ticket, not a code review comment.
core-foundation’s repository layer makes the two real answers to this problem first-class instead of something you reach for DB::raw() to hand-roll each time. lockForUpdate() takes a pessimistic lock — the second request blocks until the first one finishes, guaranteeing correctness at the cost of throughput under contention. sharedLock() is the other direction — readers don’t block each other, but a writer waits for all readers to finish. And for the cases where blocking isn’t acceptable at all — a high-throughput endpoint where you’d rather fail fast than queue — there’s updateAtomic(), a compare-and-swap: the write only lands if the row still matches the conditions you read it under, and if it doesn’t, you get a StaleDataException — a 409, not a silent overwrite — so the caller can retry with fresh data instead of corrupting it.
// Pessimistic — the second request blocks until the first one finishes
$wallet = $this->walletRepository->lockForUpdate()->fetchById($id);
// Compare-and-swap — no blocking, fails loudly instead of corrupting silently
$this->walletRepository->updateAtomic(
id: $wallet->id,
attributes: ['balance' => $newBalance],
conditions: ['balance' => $expectedBalance],
);
// throws StaleDataException (409) the moment $expectedBalance no longer matches
The detail that matters is that the caching layer knows about locking too. A repository that’s mid-lock automatically bypasses the read cache for that query, because serving a cached value while you’re about to take a lock on the real row defeats the entire point of the lock. This isn’t a rule a developer has to remember to apply correctly under pressure. It’s structural — the lock and the cache are aware of each other, so the two systems can’t quietly contradict one another.
I didn’t add this because a book told me to. I added it after finding an eleven-millisecond gap in a production wallet ledger that took two days to reproduce, because the bug only existed under real concurrent load, not in any test anyone had written. Concurrency bugs are the ones that pass code review, pass the test suite, and pass staging — and then cost you a customer’s trust in production, on exactly the day your traffic finally justified building the feature at all.
Next: what a cache is allowed to know, and why most cache invalidation bugs are actually a scoping problem wearing a TTL as a disguise.
composer require rupeshstha/core-foundation — free, MIT, part of the base architecture. Full locking, atomic updates, and what updateAtomic() bypasses (and why): Repositories