php 133 lines · 4 tabs

Debit a Wallet Balance Safely with Row Locking in Laravel

Shared by codesnips Aug 2026
4 tabs
<?php

namespace App\Http\Controllers;

use App\Models\Wallet;
use App\Services\WalletService;
use App\Exceptions\InsufficientFundsException;
use Illuminate\Http\Request;

class WalletController extends Controller
{
    public function __construct(private WalletService $wallets)
    {
    }

    public function debit(Request $request, Wallet $wallet)
    {
        $data = $request->validate([
            'amount' => ['required', 'integer', 'min:1'],
            'idempotency_key' => ['required', 'string', 'uuid'],
            'reason' => ['nullable', 'string', 'max:255'],
        ]);

        try {
            $transaction = $this->wallets->debit(
                $wallet->id,
                $data['amount'],
                $data['idempotency_key'],
                $data['reason'] ?? 'debit'
            );
        } catch (InsufficientFundsException $e) {
            return response()->json(['error' => $e->getMessage()], 422);
        }

        return response()->json([
            'transaction_id' => $transaction->id,
            'balance' => $transaction->wallet->balance,
        ], 201);
    }
}
4 files · php Explain with highlit

Debiting a wallet balance is a classic race-condition trap: two concurrent requests both read a balance of 100, both decide a 60 charge is affordable, and both write back 40 — the account goes negative or double-spends. The fix is to serialize the read-modify-write against a single row using database-level pessimistic locking, and this snippet shows the full path from HTTP request through service to model.

In the WalletController tab, the controller stays thin: it validates the incoming amount and idempotency key, resolves the wallet by route binding, then delegates to WalletService::debit. It never touches the balance directly, which keeps the locking logic in one place and out of the request layer.

The WalletService tab holds the core pattern. DB::transaction wraps the whole operation so any thrown exception rolls back cleanly. Inside, Wallet::whereKey($id)->lockForUpdate()->firstOrFail() issues a SELECT ... FOR UPDATE, taking a row-level write lock that blocks other transactions from reading that same row FOR UPDATE until this transaction commits. Crucially, the balance is re-read after acquiring the lock, so the affordability check in $wallet->debit($amount, ...) operates on fresh, isolated data rather than a stale value. An idempotency guard checks the wallet_transactions ledger first: if a row with the same idempotency_key already exists, the prior result is returned instead of debiting twice, which matters because clients retry.

The Wallet model tab encapsulates the invariant. debit throws InsufficientFundsException when amount exceeds balance, decrements the column, and records a ledger row in the same transaction so the balance and its audit trail can never drift apart.

The trade-off is throughput: lockForUpdate holds the lock for the transaction's duration, so heavy contention on one wallet serializes those requests. Keeping the transaction short — no external HTTP calls inside it — mitigates this. Alternatives like optimistic locking with a version column or an atomic UPDATE ... WHERE balance >= ? avoid held locks but complicate multi-step writes. Pessimistic locking is the clearest choice when several related rows must change atomically under a read-check-write flow.


Related snips

Share this code

Here's the card — post it anywhere.

Debit a Wallet Balance Safely with Row Locking in Laravel — share card
Link copied