Skip to content
article

Two cashiers, one last bottle of kecap: fixing overselling in a Laravel POS with row locks

Conditional updates, SELECT ... FOR UPDATE, and the deadlocks we met during the Lebaran rush

Bayu Setiawan@bayu_laravel

August 22, 2026 · 3 min read

One of my clients is a small minimarket chain, 4 stores, 6 cashiers per store at peak. Last Lebaran, the day before the holiday, the stock report showed -14 bottles of kecap manis. Negative stock. The owner asked me, very politely, how we sold 14 bottles that do not exist. Good question, mas.

The bug

$product = Product::find($id);

if ($product->stock < $qty) {
    throw new OutOfStock($product);
}

$product->decrement('stock', $qty);

Looks fine. In testing with one cashier it is fine. With two cashiers scanning the last bottle at the same moment: both read stock = 1, both pass the check, both run UPDATE products SET stock = stock - 1. The decrement itself is atomic, but the check happened before, on old data. Stock becomes -1. Multiply by Lebaran.

Fix 1: put the check inside the UPDATE

For simple "take N from stock" this is the best and fastest way. One query, no lock held in PHP:

$affected = Product::whereKey($id)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);

if ($affected === 0) {
    throw new OutOfStock($id);
}

MySQL evaluates stock >= qty and the decrement on the same row under its lock. If two cashiers race for the last bottle, one UPDATE changes 1 row, the other changes 0 rows and gets the out of stock message. Correct.

Fix 2: when you must read and then decide, lock the rows

For the full cart we need more logic (bundle promo, expiry batch FIFO for some items), so we read rows, compute, then write. For that we lock:

DB::transaction(function () use ($items) {
    $ids = collect($items)->pluck('product_id')->unique()->sort()->values();

    $products = Product::whereIn('id', $ids)
        ->orderBy('id')
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    foreach ($items as $item) {
        $p = $products[$item['product_id']];

        if ($p->stock < $item['qty']) {
            throw new OutOfStock($p);
        }

        $p->decrement('stock', $item['qty']);
    }
}, attempts: 3);

Things that matter here:

  • lockForUpdate() only works inside a transaction. Outside a transaction MySQL autocommits the SELECT and releases the lock immediately. No error, no warning, your test passes, production oversells. This one is very dangerous because it looks correct.
  • Lock in the same order every time. Cart A has kecap then sugar, cart B has sugar then kecap. A locks kecap, B locks sugar, both wait for each other: deadlock. Sorting by id and orderBy('id') means everybody locks in the same order.
  • attempts: 3 makes Laravel retry the whole closure if MySQL still reports a deadlock. With the ordering above it almost never happens, but almost is not never.
  • Use the primary key. whereIn('id', ...) locks exactly those rows. If you lock using a column without an index, InnoDB locks every row it scans, and suddenly all 6 cashiers wait for each other.

Result

Since we deployed this: zero negative stock, including one more Lebaran and two promo days. Checkout time went up about 4 ms. The owner is happy, the kecap count is honest, and I sleep during mudik season. Monggo copy if you have the same problem 🙏

Written by

Bayu Setiawan

Freelancer Laravel dari Jogja. 8 tahun bikin sistem kasir, sekolah, koperasi. Klien luar negeri juga oke. PHP masih bayar cicilan, jangan hina 🙏

Get the best of #select-star-anonymous weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

9

Sign in to join the conversation.

Sign in to comment
Ayesha Siddiqui@laravel_ayesha2 rep in SELECT * Anonymous
the 'lockForUpdate outside a transaction does nothing' line, uff. I have reviewed this exact bug from three different juniors. it looks so correct
2 net votes
Bayu Setiawan@bayu_laravel#4 in SELECT * Anonymous
Yes mbak, and no error, it just silently not lock. Very dangerous because the test pass
0 net votes
Bram Verhoeven@bram_on_a_bike#1 in SELECT * Anonymous
Lock ordering by id is the part people miss. Good post. The conditional UPDATE is the better default, lock only when you must.
1 net votes
Rizal Pratama@rgb_rizal1 rep in SELECT * Anonymous
-14 botol kecap wkwkwk 😂 mantap mas tulisannya
1 net votes
Nurul Aisyah@nurul_sql#8 in SELECT * Anonymous
confirm saving. our bank had the same race in a voucher redemption table, someone fixed it with an app-level mutex on one server. then they scaled to 3 servers lah. alamak
3 net votes
Emeka Nwosu@emeka_golang1 rep in SELECT * Anonymous
Conditional UPDATE with a RowsAffected check, same thing we do in Go for wallet balances. Language different, SQL is the same truth. Nice one Bayu, even if your freelance rates are too low 😂
2 net votes
Bayu Setiawan@bayu_laravel#4 in SELECT * Anonymous
Mas Emeka, rupiah rates in Jogja and pound rates in London are different universe wkwk. But thank you 🙏
1 net votes
John Mark Dela Cruz@jm_delacruz1 rep in SELECT * Anonymous
bai this is exactly my capstone inventory system 😭 two group mates tested at the same time and stock went negative during defense rehearsal. used the where stock >= qty one. it works po HAHAHA salamat
1 net votes
Bayu Setiawan@bayu_laravel#4 in SELECT * Anonymous
Mantap jm 🙏 now ask your group mates to click at the same time again. If stock stays 0, you pass
0 net votes