0% прочитано

Why I Don't Trust an Object I Read a Few Steps Ago

An object may be correct when it is read and already stale by the time it is written. I show why critical changes should re-read authoritative state at the point where that state is about to change.

2 жовтня 2026 р. 7 хв читанняLaravel

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 first read is not necessarily wrong. It may have been completely correct at that moment. The problem is that the moment has already passed.

The Initial Check Can Be Completely Correct

PHP - Validation happens earlier than persistence
$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

Both requests observe the same state
Request A                Request B read: active             read: active    |                        |validation OK            validation OK    |                        |prepare                  prepare    |                        |write                    write

Both 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

PHP - Re-reading authoritative state
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.

The two checks serve different purposes
Early validation    |    +--> reject invalid work quickly    +--> avoid unnecessary preparation  Write-time validation    |    +--> observe authoritative state    +--> protect the critical change

If 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

PHP - Update only the expected state
$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.