Two customers try to purchase a product at exactly the same time.
If the application isn't designed for concurrency, both requests might read stock = 1 and both complete the purchase.
This is where atomicity, transactions, and locking become important.
1. Atomicity
Atomicity is one of the four ACID properties:
- Atomicity
- Consistency
- Isolation
- Durability
Atomicity means that a group of database operations is treated as one indivisible unit.
Either:
- all operations succeed, or
- all operations are rolled back.
Example
Order::create([
'user_id' => 1,
'total' => 100,
]);
Payment::create([
'user_id' => 1,
'amount' => 100,
]);
If the order is created but the payment fails, we could end up with an inconsistent state.
A transaction allows us to make these operations atomic.
2. Transactions
Laravel provides:
DB::transaction(function () {
// database operations
});
Example:
DB::transaction(function () {
$order = Order::create([
'user_id' => 1,
'total' => 100,
]);
Payment::create([
'user_id' => 1,
'amount' => 100,
]);
});
If everything succeeds:
COMMIT
If an exception occurs:
ROLLBACK
The important idea is:
A transaction groups related database operations into one unit of work.
3. Race Conditions
A race condition happens when multiple requests access shared data concurrently and the result depends on the timing of those operations.
Imagine:
Product stock = 1
Two requests arrive:
Request A Request B
--------- ---------
Read stock = 1 Read stock = 1
Stock available Stock available
Buy product Buy product
Both requests saw the same stock value.
This can lead to incorrect behavior.
4. Pessimistic Locking
Pessimistic locking assumes that a conflict might happen.
The idea is:
Lock the data before working with it.
Laravel provides:
lockForUpdate()
and:
sharedLock()
5. lockForUpdate()
lockForUpdate() is commonly used when you need to read a row and then modify it.
DB::transaction(function () use ($productId) {
$product = Product::where('id', $productId)
->lockForUpdate()
->firstOrFail();
if ($product->stock <= 0) {
throw new Exception('Out of stock.');
}
$product->decrement('stock');
Order::create([
'product_id' => $product->id,
]);
});
The important part is:
->lockForUpdate()
For a simple primary-key lookup, you can think of this as locking the matching row.
6. What happens with two requests?
Suppose:
Product #1
stock = 1
Request A:
BEGIN TRANSACTION
↓
Lock Product #1
↓
Read stock = 1
↓
Decrease stock
↓
Create order
↓
COMMIT
↓
Unlock
Request B tries to lock the same row while A still has the lock:
BEGIN TRANSACTION
↓
Try to lock Product #1
↓
WAIT...
After Request A commits, the lock is released and Request B can continue.
Request B then sees the updated stock.
7. Does lockForUpdate() lock the whole table?
Usually, no.
For:
$product = Product::where('id', $productId)
->lockForUpdate()
->firstOrFail();
you can generally think of the matching row as being locked.
For example:
products
id stock
------------
1 10 available
2 20 LOCKED
3 30 available
The exact locking behavior depends on the database engine, indexes, isolation level, and query.
For a simple primary-key lookup, thinking in terms of a row-level lock is appropriate.
8. sharedLock()
Laravel also provides:
->sharedLock()
Example:
DB::transaction(function () {
$product = Product::where('id', 1)
->sharedLock()
->firstOrFail();
// Protected read
});
A useful mental model is:
sharedLock()
↓
"I'm reading this data and want protected access."
while:
lockForUpdate()
↓
"I'm reading this because I'm going to modify it."
9. Optimistic Locking
Optimistic locking takes a different approach.
Instead of locking the row, we assume conflicts are relatively uncommon.
The idea is:
Don't lock the record. Detect whether somebody else changed it when you try to update it.
A common implementation uses a version column.
Example:
users
id balance version
------------------------
1 1000 5
The application reads:
balance = 1000
version = 5
Later, it updates only if the version is still 5:
UPDATE users
SET balance = 500,
version = 6
WHERE id = 1
AND version = 5;
If one row is updated, the operation succeeded.
If zero rows are updated, someone else changed the record.
10. Optimistic Locking in Laravel
Laravel/Eloquent doesn't provide optimistic locking as a standard built-in feature like lockForUpdate().
You can implement it yourself.
$user = User::findOrFail($id);
$version = $user->version;
$updated = User::where('id', $user->id)
->where('version', $version)
->update([
'balance' => 500,
'version' => $version + 1,
]);
if ($updated === 0) {
throw new RuntimeException(
'The record was modified by another process.'
);
}
The important part is:
Read version
↓
Work
↓
UPDATE ... WHERE version = old_version
↓
Success → nobody changed it
Failure → somebody changed it
11. Pessimistic vs Optimistic Locking
| Pessimistic | Optimistic | |
|---|---|---|
| Strategy | Lock first | Detect conflict later |
| Blocks concurrent access | Yes | No |
| Database row lock | Yes | Usually no |
| Version column | Not required | Usually |
| Good when | Conflicts are likely | Conflicts are uncommon |
| Laravel | lockForUpdate() |
Usually custom implementation |
Easy way to remember:
Pessimistic: "I expect a conflict, so I'll lock it."
Optimistic: "I don't expect a conflict, so I'll detect it if it happens."
12. Deadlocks
A deadlock occurs when two transactions are waiting for each other's locks.
For example:
Transaction A Transaction B
Lock User #1 Lock User #2
↓ ↓
Try User #2 Try User #1
↓ ↓
WAIT WAIT
Now:
A waits for B
B waits for A
The database detects the deadlock and normally aborts one of the transactions.
How to reduce deadlocks
- Keep transactions short.
- Lock resources in a consistent order.
- Avoid unnecessary locks.
- Avoid slow operations inside transactions.
- Retry transactions when appropriate.
13. Atomic Updates
Sometimes you don't need an explicit lock.
Instead of:
$product = Product::find($id);
if ($product->stock > 0) {
$product->decrement('stock');
}
you can make the condition and update part of the same SQL statement:
$updated = Product::where('id', $id)
->where('stock', '>', 0)
->decrement('stock');
if ($updated === 0) {
throw new Exception('Out of stock.');
}
Conceptually, this becomes:
UPDATE products
SET stock = stock - 1
WHERE id = ?
AND stock > 0;
The database performs the condition and update together.
14. Cache::lock()
Laravel also provides application-level atomic locks:
Cache::lock()
Example:
$lock = Cache::lock("process-order:{$orderId}", 10);
if ($lock->get()) {
try {
// Critical section
} finally {
$lock->release();
}
}
This is different from:
lockForUpdate()
A useful distinction is:
lockForUpdate()
↓
Database row locking
Cache::lock()
↓
Application/distributed locking
Cache::lock() is useful when multiple workers or servers need to coordinate access to the same logical resource.
15. Transactions vs Locks
This distinction is extremely important.
A transaction answers:
"Which operations should succeed or fail together?"
A lock answers:
"How should concurrent operations access the same data?"
Think:
Transaction
↓
Atomicity
↓
COMMIT / ROLLBACK
Lock
↓
Concurrency control
↓
Prevent or detect conflicts
You often use both together:
DB::transaction(function () use ($productId) {
$product = Product::where('id', $productId)
->lockForUpdate()
->firstOrFail();
if ($product->stock <= 0) {
throw new Exception('Out of stock.');
}
$product->decrement('stock');
Order::create([
'product_id' => $product->id,
]);
});
Here:
-
DB::transaction()provides atomicity. -
lockForUpdate()provides pessimistic concurrency control. - The application prevents two requests from purchasing the same final item.
16. Interview Questions
What is atomicity?
Atomicity means that a group of database operations is treated as a single unit. Either all operations are committed or they are all rolled back.
What is a transaction?
A transaction groups database operations together and provides commit and rollback semantics.
What is a race condition?
A race condition occurs when concurrent operations access shared state and the result depends on their timing or ordering.
What is lockForUpdate()?
lockForUpdate()is Laravel's pessimistic row-locking mechanism. It is used when a transaction needs to read and modify rows while preventing conflicting concurrent updates.
What is optimistic locking?
Optimistic locking doesn't block concurrent access. Instead, it detects whether a record changed between reading and updating, commonly using a version column.
Does Laravel have built-in optimistic locking?
Eloquent doesn't provide optimistic locking as a standard built-in feature like
lockForUpdate(). It is commonly implemented using a version or timestamp check.
What is a deadlock?
A deadlock occurs when transactions hold locks that the other transactions need, causing them to wait for each other.
What is the difference between optimistic and pessimistic locking?
Pessimistic locking prevents conflicts by locking the resource before working with it. Optimistic locking assumes conflicts are uncommon and detects them when updating.
Final Mental Model
CONCURRENCY
|
+--------------+--------------+
| |
TRANSACTIONS LOCKS
| |
Atomicity +-------+-------+
| | |
COMMIT/ROLLBACK Pessimistic Optimistic
| |
lockForUpdate() version check
sharedLock()
The key ideas to remember:
Atomicity
↓
All operations succeed or all fail.
Transaction
↓
Groups operations into one unit of work.
Pessimistic locking
↓
Lock first, then work.
Optimistic locking
↓
Work first, detect conflicts when saving.
Race condition
↓
Concurrent operations produce an incorrect or unexpected result.
Deadlock
↓
Transactions wait for each other's locks.
For a Laravel interview, these concepts give you a strong foundation for understanding database concurrency.