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.