3 min read

Using Database Transactions in Laravel

How I use Laravel transactions to keep related database writes together, with a balance-transfer example and practical limitations.

  • Databases
  • Reliability

The first time I built a feature with several related database operations, I had a simple concern: what happens if one write succeeds and the next one fails?

A balance transfer makes the problem easy to see:

$sender = User::findOrFail(1);
$sender->balance -= 100000;
$sender->save();

// An exception here leaves only the first change stored.

$receiver = User::findOrFail(2);
$receiver->balance += 100000;
$receiver->save();

If the process stops between the two updates, the sender has already lost the balance but the receiver has not received it. A database transaction keeps the related writes in one boundary: Laravel commits them together or rolls them back when an exception escapes the transaction.

The Transaction Closure

The closure form is the simplest option I commonly use:

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $sender = User::findOrFail(1);
    $receiver = User::findOrFail(2);

    $sender->balance -= 100000;
    $sender->save();

    $receiver->balance += 100000;
    $receiver->save();
});

When the closure finishes, Laravel commits the transaction. If an exception is thrown, Laravel rolls it back and rethrows the exception.

The closure can also return a value:

$order = DB::transaction(function () use ($attributes) {
    return Order::create($attributes);
});

Manual Control

Laravel also provides beginTransaction(), commit(), and rollBack(). I find the boundary easy to see in this form, but every failure path must be handled correctly:

DB::beginTransaction();

try {
    // Related database operations...

    DB::commit();
} catch (\Throwable $error) {
    DB::rollBack();
    report($error);

    throw $error;
}

The rethrow matters. Swallowing the exception can make the caller believe the operation succeeded.

A Safer Balance-Transfer Example

The following is a compact illustration, not a complete payment system. It adds the checks that are directly related to the transaction:

use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

$validated = $request->validate([
    'receiver_id' => ['required', 'integer', 'exists:users,id'],
    'amount' => ['required', 'integer', 'min:1000'],
]);

$senderId = (int) $request->user()->id;
$receiverId = (int) $validated['receiver_id'];

if ($senderId === $receiverId) {
    throw ValidationException::withMessages([
        'receiver_id' => 'The sender and receiver must be different.',
    ]);
}

$userIds = [$senderId, $receiverId];
sort($userIds);

DB::transaction(function () use (
    $userIds,
    $senderId,
    $receiverId,
    $validated
) {
    $users = User::query()
        ->whereIn('id', $userIds)
        ->orderBy('id')
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    $sender = $users->get($senderId);
    $receiver = $users->get($receiverId);
    $amount = (int) $validated['amount'];

    if (! $sender || ! $receiver) {
        throw ValidationException::withMessages([
            'receiver_id' => 'The sender or receiver no longer exists.',
        ]);
    }

    if ($sender->balance < $amount) {
        throw ValidationException::withMessages([
            'amount' => 'The available balance is insufficient.',
        ]);
    }

    $sender->balance -= $amount;
    $sender->save();

    $receiver->balance += $amount;
    $receiver->save();

    Transaction::create([
        'sender_id' => $sender->id,
        'receiver_id' => $receiver->id,
        'amount' => $amount,
        'type' => 'transfer',
    ]);
}, attempts: 5);

The rows are locked in ID order so two transfers involving the same accounts use a consistent lock order. The attempts argument tells Laravel how many times it may retry the transaction after a deadlock.

This example assumes balances are stored in the smallest currency unit as integers. A real transfer flow still needs rules for authorization, duplicate requests, limits, and audit requirements.

What a Transaction Does Not Solve

A transaction protects its database writes from partial completion. It does not automatically:

  • prevent a client from submitting the same command twice;
  • undo an email or external API call that already happened;
  • include writes made through another database connection;
  • make long-running work safe to execute while rows remain locked.

Those concerns need separate mechanisms when they are part of the workflow. The related tables also need to use a storage engine that supports transactions. For the problem that first led me to transactions, however, the main lesson was straightforward: related writes should either succeed together or not be stored at all.

References