DEV Community

Harry Douglas
Harry Douglas

Posted on

Laravel Concurrency: Atomicity, Transactions, and Locking

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,
]);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

Example:

DB::transaction(function () {
    $order = Order::create([
        'user_id' => 1,
        'total' => 100,
    ]);

    Payment::create([
        'user_id' => 1,
        'amount' => 100,
    ]);
});
Enter fullscreen mode Exit fullscreen mode

If everything succeeds:

COMMIT
Enter fullscreen mode Exit fullscreen mode

If an exception occurs:

ROLLBACK
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two requests arrive:

Request A                 Request B
---------                 ---------
Read stock = 1            Read stock = 1
Stock available           Stock available
Buy product               Buy product
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

and:

sharedLock()
Enter fullscreen mode Exit fullscreen mode

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,
    ]);
});
Enter fullscreen mode Exit fullscreen mode

The important part is:

->lockForUpdate()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Request A:

BEGIN TRANSACTION
        ↓
Lock Product #1
        ↓
Read stock = 1
        ↓
Decrease stock
        ↓
Create order
        ↓
COMMIT
        ↓
Unlock
Enter fullscreen mode Exit fullscreen mode

Request B tries to lock the same row while A still has the lock:

BEGIN TRANSACTION
        ↓
Try to lock Product #1
        ↓
WAIT...
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Example:

DB::transaction(function () {
    $product = Product::where('id', 1)
        ->sharedLock()
        ->firstOrFail();

    // Protected read
});
Enter fullscreen mode Exit fullscreen mode

A useful mental model is:

sharedLock()
    ↓
"I'm reading this data and want protected access."
Enter fullscreen mode Exit fullscreen mode

while:

lockForUpdate()
    ↓
"I'm reading this because I'm going to modify it."
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The application reads:

balance = 1000
version = 5
Enter fullscreen mode Exit fullscreen mode

Later, it updates only if the version is still 5:

UPDATE users
SET balance = 500,
    version = 6
WHERE id = 1
AND version = 5;
Enter fullscreen mode Exit fullscreen mode

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.'
    );
}
Enter fullscreen mode Exit fullscreen mode

The important part is:

Read version
      ↓
Work
      ↓
UPDATE ... WHERE version = old_version
      ↓
Success → nobody changed it
Failure → somebody changed it
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now:

A waits for B
B waits for A
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

Conceptually, this becomes:

UPDATE products
SET stock = stock - 1
WHERE id = ?
AND stock > 0;
Enter fullscreen mode Exit fullscreen mode

The database performs the condition and update together.

14. Cache::lock()

Laravel also provides application-level atomic locks:

Cache::lock()
Enter fullscreen mode Exit fullscreen mode

Example:

$lock = Cache::lock("process-order:{$orderId}", 10);

if ($lock->get()) {
    try {
        // Critical section
    } finally {
        $lock->release();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is different from:

lockForUpdate()
Enter fullscreen mode Exit fullscreen mode

A useful distinction is:

lockForUpdate()
    ↓
Database row locking

Cache::lock()
    ↓
Application/distributed locking
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    ]);
});
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

For a Laravel interview, these concepts give you a strong foundation for understanding database concurrency.

Top comments (0)