Laravel cookbook · Intermediate

Prevent race conditions with transactions and row locks

Two requests redeem the same voucher or oversell the same stock.

Create a free account to save this page, take notes and track your progress. Sign in

Approach

Wrap the read-check-write in DB::transaction() and lock the rows with lockForUpdate(), or use a single conditional update. Pass an attempt count so deadlocks are retried.

Solution

<?php
namespace App\Actions;

use App\Exceptions\VoucherUnavailable;
use App\Models\User;
use App\Models\Voucher;
use Illuminate\Support\Facades\DB;

class RedeemVoucher
{
    public function __invoke(User $user, string $code): Voucher
    {
        return DB::transaction(function () use ($user, $code) {
            $voucher = Voucher::where('code', $code)->lockForUpdate()->firstOrFail();

            if ($voucher->redeemed_at !== null || $voucher->expires_at->isPast()) {
                throw new VoucherUnavailable($code);
            }

            $voucher->update(['redeemed_at' => now(), 'redeemed_by' => $user->id]);
            $user->increment('credit_cents', $voucher->value_cents);

            return $voucher;
        }, attempts: 3);
    }
}

// Single-statement alternative: only one request can match the WHERE clause.
$updated = DB::table('products')
    ->where('id', $productId)
    ->where('stock', '>=', $quantity)
    ->decrement('stock', $quantity);

Pitfalls

  • Checking a value, then updating it in a separate query without a lock.
  • Sending emails or calling APIs inside the transaction; dispatch them after commit.
  • Locking rows in different orders in different code paths, causing deadlocks.

Variations

  • Cache::lock('voucher:'.$code, 10)->block(5, fn () => ...) serialises work across servers without row locks.
  • Add unique indexes (one redemption per user) as a last line of defence.

Further reading: Pessimistic locking

Reference: https://laravel.com/docs/13.x/queries#pessimistic-locking

More Laravel recipes

Was this page helpful?