Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Prevent Laravel Overselling with Transactions and `lockForUpdate`

In a Laravel store, two purchase requests can both read that one item remains. If each request checks the stock before either updates it, both orders can succeed and the store oversells.

A database transaction groups related changes so they either all commit or all roll back together. Inside the transaction, Laravel’s lockForUpdate() asks the database to lock a selected row so another transaction cannot make a conflicting change to it. A second purchase request that needs the same row must wait. After the first request finishes, the second request checks the updated stock. This works when every code path that sells or reserves the stock uses the same lock and transaction.

Why a stock check alone can oversell

When a request checks stock and updates it in separate steps, a race condition can occur: the result depends on which request reaches the database first. For example, if the product has one unit, two requests can both read stock_qty = 1. Each then passes its check and creates an order.

A locking read locks the product row while the request checks and updates its stock. Laravel’s lockForUpdate() query must run inside a transaction. The database holds the lock until the transaction commits or rolls back. In MySQL InnoDB, a FOR UPDATE read blocks other transactions from taking conflicting locks on the selected rows. Ordinary reads that do not request a lock can still proceed, as the MySQL locking-read documentation explains.

Lock the product before checking its stock

Validate that the requested quantity is a positive integer before opening the transaction. Then open the transaction, lock the product row, and check its current stock. Reduce the stock and create the order before committing the transaction:

use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

$order = DB::transaction(function () use ($productId, $quantity) {
    $product = Product::query()
        ->whereKey($productId)
        ->lockForUpdate()
        ->firstOrFail();

    if ($product->stock_qty < $quantity) {
        throw ValidationException::withMessages([
            'quantity' => 'There is not enough stock for this order.',
        ]);
    }

    $product->stock_qty -= $quantity;
    $product->save();

    return Order::create([
        'product_id' => $product->id,
        'quantity' => $quantity,
    ]);
});

The order and stock update share one transaction. If stock is insufficient, the exception rolls back the transaction, undoing any stock change and preventing Laravel from saving the order. In the one-unit example, the first request locks the product, reduces its stock, and commits. The waiting request then gets the lock and sees that the remaining stock is too low for its order.

Call lockForUpdate() before the query executes. Calling it after retrieving the product does not lock the row. Also make sure the product update and order creation use the same database connection; otherwise, the transaction cannot make both changes succeed or fail together.

Keep the lock focused and short

Lock only the product rows the purchase needs, and keep external work outside the transaction. A payment gateway call or email, like any other network request, can take time. Holding a database lock during that wait makes other purchases of the same product wait too.

For a cart with multiple products, lock the product rows in the same order, such as by ascending product ID. If two requests lock the same products in opposite orders, each can wait for a row held by the other. This deadlock forces the database to cancel one transaction. Keep transactions short and lock rows in the same order. Use indexes that help the database find the rows it needs as well. Together, these steps help reduce deadlocks, as described in Laravel deadlock guidance.

A database transaction undoes database changes after an exception, but it cannot undo actions outside the database. Do not charge a customer or send a confirmation from inside the transaction unless the integration explicitly supports that behavior. If payment happens after the order is created, keep the stock reserved while payment is pending. If payment fails or the reservation expires, release the stock in a separate transaction only while the reservation is still active, so repeated callbacks do not restore the same stock twice.

Make every stock-changing path follow the same rule

lockForUpdate() protects the row only from other transactions that request conflicting database locks. A checkout route that skips the lock can still interfere with inventory. The same is true of an admin stock adjustment that does not follow the same transaction rules.

Use the database as the final authority when accepting an order. A cache can show availability to shoppers, but a stale cached value must not decide whether the purchase succeeds. The booking guidance on availability and reservations also emphasizes accounting for pending reservations when deciding what remains available.

Test simultaneous purchases against the same database engine used in production. Locking behavior depends on the database and its transaction settings, so a test with a different engine does not show how production requests will behave. A serialization failure means the database rejected a transaction because of conflicting changes. If a deadlock or serialization failure occurs, retry the complete transaction only when the surrounding request handles repeated execution safely.

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Email updates

Usually a new article and a few links I found interesting.

No spam. Unsubscribe with one click.

Leave a Reply

Your email address will not be published. Required fields are marked *