When multiple requests may update the same record concurrently, I often need a transaction and a row lock.
But that creates another easy mistake: opening the transaction too early and performing unrelated work while the lock is held.
Input transformation, object construction, expensive calculations, or external calls do not become safer just because they happen inside a transaction.
They simply make the lock last longer.
I therefore keep only the work that truly depends on current database state inside the critical section.
I Separate Preparation from the Critical Write
prepare | vtransaction | vlock | vverify | vwrite | vcommitBefore the transaction, I perform everything that does not require the current row to be locked.
Then I start the transaction, re-read the required record with a lock, verify its current state, and perform the write immediately.
The less work happens between lockForUpdate() and transaction completion, the less time another concurrent request has to wait.
Starting the Transaction Too Early Holds the Lock Longer Than Necessary
DB::transaction(function () use ($recordId, $input): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail(); $normalized = $this->normalizer->normalize( $input, ); $payload = $this->builder->build( $normalized, ); $record->apply( $payload,In this example, normalization and payload construction do not depend on the current state of the record.
But the row is already locked.
While that unrelated preparation runs, another request that needs the same row simply waits.
The transaction is technically correct, but the critical section is wider than necessary.
I Perform Lock-Independent Work Before the Transaction
$normalized = $this->normalizer->normalize( $input,); $payload = $this->builder->build( $normalized,); DB::transaction(function () use ( $recordId, $payload,): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail();Now only three important steps remain under the lock:
- read the current record;
- verify that the operation is still allowed;
- perform the write.
This is the part that genuinely requires ordering relative to concurrent changes.
I Revalidate Current State Before the Write
Preparing outside the transaction does not mean I trust an old in-memory object.
If the decision depends on state that may have changed, I re-read that state inside the transaction.
The row obtained after lockForUpdate() becomes the basis for the final verification.
Previously prepared data is used only after current state confirms that the operation is still valid.
Not Every Validation Needs to Run Under the Lock
Before transaction: - input format- required fields- normalization- type conversion- value preparation Under the lock: - current record state- whether the operation is still allowed- whether critical relationships changed- final writeI separate validation by what it depends on.
If a check depends only on the request itself, there is little reason to hold a database lock while performing it.
If it answers a question about current persisted state, it belongs after the authoritative row has been re-read.
An External Call While Holding a Lock Is a Warning Sign for Me
DB::transaction(function () use ($recordId): void { $record = Record::query() ->whereKey($recordId) ->lockForUpdate() ->firstOrFail(); $result = $externalService->request(); $record->applyExternalResult( $result, ); $record->save();});An external system may respond in 20 milliseconds or several seconds.
While we wait, the database lock remains active.
For me, a network call inside a locked section is always a reason to recheck the operation boundary.
Sometimes that ordering is genuinely required, but it should be intentional rather than accidental.
Lock Duration Affects More Than One Request
Request A lock | |-- unrelated work ----| | | |---- verify + write ---| |commit Request B lock | +---- WAIT ----+Shortening a transaction is not only a local optimization of one method.
It reduces the time during which other requests cannot continue with the same resource.
This matters especially for records that may receive frequent concurrent updates.
State-Dependent Decisions Must Still Stay Inside the Transaction
$record = Record::query() ->findOrFail($recordId); if (! $record->canBeChanged()) { throw WriteConflict::detected();} DB::transaction(function () use ($record): void { $record->applyChange(); $record->save();});Shortening the transaction does not mean moving every check outside it.
In this example, another request may change the row between canBeChanged() and the actual write.
The check was correct when it ran but no longer guarantees correctness at write time.
State that protects the operation invariant therefore needs to be checked after locking.
The Transaction Boundary Should Match the Atomic Change
I do not try to make transactions short at any cost.
Their primary responsibility is still to ensure that changes which must happen together actually happen together.
If correctness requires two records to change atomically, both writes remain inside the transaction.
I remove only work that is unrelated to that atomic guarantee.
Preparation Failures Are Better Before the Lock When Possible
There is another simple benefit to preparing before the transaction.
If the input cannot be normalized, converted, or prepared, the request may fail before locking any row.
An obviously unsuccessful operation therefore does not create unnecessary contention for other requests.
What I Verify in Tests
1. Valid input -> current row is read under lock -> state is verified -> change is persisted 2. State changes before transaction starts -> re-read sees the new state -> write is rejected 3. Two concurrent requests -> second request cannot bypass verification -> conflicting writes do not both succeed What I Keep Inside the Locked Section
Before considering the flow complete, I check:
- Can request data be prepared before opening the transaction?
- Is any lock-independent computation happening while the row is locked?
- Is there an accidental external call under the lock?
- Is authoritative state re-read before the critical change?
- Is the invariant verified after locking?
- Do all atomically related writes remain in one transaction?
- Does the transaction finish immediately after the required writes?
The Lock Should Protect the Critical Change, Not the Entire Method
For me, a short transaction is not a transaction with the fewest possible lines of code.
It is a transaction that contains only work requiring atomicity or current locked state.
Data can be prepared first. Then the transaction starts, the row is locked, current state is revalidated, the write is performed, and the operation completes immediately.
The lock then protects the critical change rather than unrelated preparation around it.
My goal is not "less transactional code". It is less time between lock and commit without weakening correctness guarantees.
I try to hold the lock only around the part of the flow that truly needs serialized access to current state.



