Atomic invariants
Either the invariant is recorded in the same atomic step that checked it, or the invariant does not exist.
Everything else — a check a few lines above the write, a lock released before the update, a "nobody clicks twice" assumption — is a race that shows up under exactly the conditions you care about: a retry, a double tap, a load spike, two workers on the same queue.
1. The shape of the bug
// ❌ two requests both read "not used yet" and both proceed
$coupon = Coupon::whereCode($code)->firstOrFail();
abort_if($coupon->used_at !== null, 409);
$coupon->update(['used_at' => now()]);
The gap between the read and the write is where the second request fits. It does not need to be wide: two requests arriving in the same millisecond is the normal case for a double tap on a phone, a retried mobile request, or two queue workers.
// ✅ read and write in the same transaction, with the row locked
DB::transaction(function () use ($code) {
$coupon = Coupon::whereCode($code)->lockForUpdate()->firstOrFail();
abort_if($coupon->used_at !== null, 409);
$coupon->update(['used_at' => now()]);
});
The lock is held until the invariant is recorded. A lockForUpdate() whose update() happens after the
closure returns bought nothing at all.
2. Back it with a constraint where the rule demands it
A transaction is the fast path. A unique constraint is the one that is still true under a deploy, a replica that lags, a second service that writes the same table, or a code path someone adds next year and forgets to wrap.
ALTER TABLE coupon_redemptions ADD UNIQUE KEY uq_coupon_once (coupon_id);
Then treat the constraint violation as a normal outcome, not as a 500: catch it and return the same answer the check would have returned. A constraint you never expect to fire is a constraint you never handled.
The rule of thumb: if two rows would be a business problem, the database says so. If they would only be untidy, the transaction is enough.
3. Check the write actually happened
$affected = Coupon::whereCode($code)->whereNull('used_at')->update(['used_at' => now()]);
if ($affected === 0) {
// somebody else got there first — this is the answer, not an error
}
A conditional update that returns its affected-row count is often simpler than a lock, and it is atomic by construction: the condition and the write are one statement. Then read the count. Ignoring it turns the whole pattern back into a check-then-act.
Same for ownership checks inside a transaction: verify, write, and confirm the row count before answering success.
4. Idempotency is the same rule, seen from the caller
Mobile networks retry. Queues retry. Users double tap. So a mutating operation that must happen once takes an idempotency key generated by the caller at the moment of intent, and the server records that key in the same transaction as the effect. The second request with the same key returns the first result instead of doing the work again.
A key generated server-side per request is not an idempotency key — it is a request id.
5. Where the check must not be
- Not in the client. A disabled button, a guard in the UI, a confirmation dialog: those are user experience. The invariant is enforced where the write happens.
- Not in a cache. A "have we seen this already" lookup in a cache without a durable record is a hint, not a control, and it fails open on a restart or an eviction.
- Not in a separate service call whose result is used a few lines later — that is a check-then-act with a network hop in the gap.
6. When failing open is the right call — and when it is not
A rate limiter that fails open when its store is unavailable is a deliberate and usually correct choice: rate limiting is an availability control, and failing it closed turns a cache blip into a full outage. Authorization sits upstream and does not depend on it.
A single-use invariant that fails open is a defect, always: it means the thing that must happen once can happen twice.
Write down which of the two you are building, next to the code. The difference is not visible from the implementation, and the next person will assume the wrong one.
How to find it in a diff
# a check followed by an update, no transaction in sight
rg -n -B2 -A6 "(firstOrFail|first\(\)|find\()" app/ src/ | rg -n "update\(|save\(" | head
# locks whose transaction boundary is unclear
rg -n "lockForUpdate|FOR UPDATE|SELECT .* FOR SHARE" app/ src/
# update() whose return value is discarded
rg -n "^\s*(\\\$\w+->)?update\(" app/ | rg -v "^\s*\\\$\w+\s*="
# single-use wording without a constraint nearby
rg -n -i "used_at|redeemed|consumed|one[_-]?time|nonce|single[_-]?use" app/ src/ database/
Gotchas
- A transaction that spans a network call holds locks for the duration of that call. Do the external work outside; keep the invariant's read and write inside.
- The framework's "unique" validation rule is not a constraint. It is a query before the insert, which is exactly the race this skill is about.
- Two workers is not a hypothetical. Any queue with concurrency above one, any horizontally scaled app, any retry — the second execution is the normal case, not the edge.
- A test cannot usually reproduce this alone. Test the pieces: assert the constraint exists, assert the conditional update returns zero on the second call, assert the violation is handled as a business outcome.
- "It has never happened" is a statement about traffic, not about correctness. It starts happening on the day the campaign goes out.
Checklist
- The read of the invariant and the write that records it are in one atomic step
- The lock (if any) is held until after the write, inside the same transaction
- A unique constraint backs the rule where two rows would be a business problem
- The constraint violation is handled as an outcome, not as a 500
- Affected-row counts are read, not discarded
- Idempotency key generated by the caller, recorded with the effect
- Fail-open vs fail-closed is a written decision, next to the code
Final report
Invariant: <what must hold once>
Enforced: <transaction + lock | conditional update + row count | unique constraint>
Constraint present: yes <name> | no, because <reason>
Violation path: <what the caller gets>
Idempotency: <key source, where recorded> | not applicable
Fail mode under store outage: open | closed — and why