One dangerous illusion in application code looks very simple: I read an object from the database, validate its state, and continue processing.
At that moment, it is easy to treat that object as current truth.
But dozens of lines of code, an external call, object preparation, or simply a few milliseconds may pass before the write happens.
During that time, another request may change the same record.
For critical operations, I therefore use a different rule: an object read earlier is useful for an initial decision, but authoritative state should be confirmed again at the point where the write happens.
The Initial Check Can Be Completely Correct
$record = $repository->findById($id); if ($record === null) { throw ResourceNotFound::forId($id);} if (! $record->canBeChanged()) { throw ChangeNotAllowed::forCurrentState();} $replacement = $this->prepareReplacement($record); // More work happens here... $repository->saveReplacement( $record,There is no obvious bug in this code.
We read the record, verify that it may be changed, prepare the replacement, and pass everything to persistence.
The problem becomes visible only when a second request exists.
The initial check answers: was this operation allowed when I read the record?
The write needs a different question: is it still allowed now?
Two Requests Can Legitimately Read the Same State
Request A Request B read: active read: active | |validation OK validation OK | |prepare prepare | |write writeBoth requests may be completely valid before persistence begins.
Both read active. Both pass validation. Both prepare a valid change.
If both then simply write, the final result depends on execution timing.
That may be acceptable for some non-critical data. It is not enough when only one competing request should be allowed to win.
An In-Memory Object Is a Snapshot, Not Live Database State
When Eloquent or a repository returns an object, that object does not remain automatically synchronized with the database.
It is a snapshot of state at read time.
If another process changes the row a millisecond later, my PHP object does not know about it.
That is why I do not treat previously loaded state as final proof before a critical write.
An in-memory object tells me what was true when I read it. The database tells me what is true when I write.
Before a Critical Write I Return to the Source of Truth
DB::transaction(function () use ($record): void { $current = ResourceRecord::query() ->whereKey( $record->id(), ) ->lockForUpdate() ->first(); if ( $current === null || $current->status !== 'active' ) { throw WriteConflict::detected(); } // Critical write happens here.At the critical point, I open a short transaction and re-read the required row.
lockForUpdate() serves two purposes here.
First, I get authoritative state immediately before the change.
Second, another competing write cannot complete an incompatible change to the same row while the current transaction owns the lock.
The decision is then based not on an old PHP object, but on state confirmed by the database at the critical moment.
This Does Not Make the Early Check Useless
Re-reading state before persistence does not mean the initial validation should disappear.
The early check is still useful.
It allows obviously invalid operations to fail quickly without doing unnecessary preparation work.
The second check inside the transaction solves a different problem: it protects the correctness of the write when state changes between the first read and persistence.
Early validation | +--> reject invalid work quickly +--> avoid unnecessary preparation Write-time validation | +--> observe authoritative state +--> protect the critical changeIf the State Changed, I Do Not Silently Continue
If the authoritative re-read shows that state has already changed, that is not merely a technical inconvenience.
It means the original decision was based on data that is no longer current.
In that situation, I prefer an explicit write conflict instead of trying to force an old operation through.
A higher layer can then decide what happens next: return a conflict to the client, retry a safe operation, or read the new state.
I Do Not Hold the Lock During All Preparation Work
This does not mean the entire operation should run under lockForUpdate().
I try to keep the transaction as short as possible.
Anything that does not require the authoritative locked row can be prepared earlier.
The critical section should contain only work that genuinely needs the guarantee:
- re-read state;
- verify important conditions;
- perform the write;
- commit the transaction.
Sometimes a Conditional Update Is Enough
$updated = ResourceRecord::query() ->whereKey($id) ->where('status', 'active') ->update([ 'status' => 'completed', ]); if ($updated !== 1) { throw WriteConflict::detected();}lockForUpdate() is not the only way to protect a concurrent state change.
For a simple state transition, a conditional UPDATE may be enough: update the row only if it still has the expected state.
If zero rows are updated, I know that something changed between my original read and the write.
The choice between locking and a conditional update depends on the operation. The main principle remains the same: the write should not silently depend on stale state.
Application Checks Do Not Replace Database Guarantees
Even a correct authoritative re-check does not mean every important rule should exist only in PHP.
If a rule can be represented by a UNIQUE constraint, foreign key, or another database constraint, I use that as the final guard.
Under concurrency, the database ultimately decides which write actually becomes persistent.
The application should handle that result correctly rather than assuming an earlier check made conflicts impossible.
When I Treat Previously Read State as Potentially Stale
I explicitly think about concurrency whenever one or more of these conditions exist between the read and the write:
- the operation changes critical state;
- two requests may work with the same row at the same time;
- only one winner is allowed;
- an important uniqueness rule must not be violated;
- additional work happens between the read and the write;
- the decision depends on a particular current status;
- an invalid intermediate state may affect later operations.
In those places, asking "did I already validate this?" is not enough. I ask: when did I validate it, and could the state have changed since then?
Read Time and Write Time Are Different Moments
This is the main lesson I take from concurrent state changes.
An object loaded from the database is not a live representation of current state. It represents state at one moment in time.
An early check is useful for a fast decision, but a critical write may require another check closer to persistence.
Sometimes that means lockForUpdate(). Sometimes a conditional UPDATE is enough. Important rules may also need database constraints as an additional guarantee.
I do not try to make an old object current. I simply stop asking it to provide a guarantee it cannot provide.
I trust the first read to begin the work. For a critical write, I trust the state verified at the point where the change happens.



