Database Transactions
All-or-nothing writes: commit only when every step succeeds, roll back on any failure.
Some operations must happen together or not at all: debit one account, credit another. If the second write fails, the first must be undone — otherwise money vanishes. That is what a transaction guarantees: atomicity.
The closure form (preferred)
Pass a closure to DB::transaction(). If it returns normally, Laravel commits. If it throws, Laravel rolls back and re-throws. You cannot forget to roll back because there is nothing to forget.
use Illuminate\Support\Facades\DB;
DB::transaction(function () {
$from->decrement('balance', 100);
$to->increment('balance', 100);
});Deadlocks & retries
Under concurrency the database may abort a transaction to break a deadlock. The second argument to DB::transaction() is a retry count — Laravel will replay the whole closure that many times.
For finer control there is the manual trio: DB::beginTransaction(), DB::commit(), DB::rollBack() — but reach for the closure unless you genuinely need to interleave logic.
Example
<?php
namespace App\Actions;
use App\Models\Account;
use App\Models\Transfer;
use Illuminate\Support\Facades\DB;
class TransferFunds
{
public function handle(Account $from, Account $to, int $cents): Transfer
{
// Retries the whole closure up to 3 times on deadlock.
return DB::transaction(function () use ($from, $to, $cents) {
// Pessimistic lock so two concurrent transfers can't oversell.
$from = Account::whereKey($from->id)->lockForUpdate()->firstOrFail();
if ($from->balance_cents < $cents) {
throw new \DomainException('Insufficient funds.');
}
$from->decrement('balance_cents', $cents);
$to->increment('balance_cents', $cents);
$transfer = Transfer::create([
'from_id' => $from->id,
'to_id' => $to->id,
'cents' => $cents,
]);
// Side effect AFTER the commit, never inside the transaction.
DB::afterCommit(fn () => \App\Jobs\NotifyTransfer::dispatch($transfer));
return $transfer;
}, attempts: 3);
}
}When to use it
- An order placement service wraps stock decrement and order creation in a transaction so a failed payment leaves the inventory unchanged.
- A funds transfer between two wallet records uses a transaction to guarantee both the debit and credit succeed or both are rolled back atomically.
- A bulk import job wraps the entire CSV insert in a transaction and catches exceptions to roll back all rows if any one row fails validation.
More examples
DB::transaction() with a closure
DB::transaction() automatically commits if the closure completes without exceptions and rolls back if any exception is thrown.
use Illuminate\Support\Facades\DB;
$order = DB::transaction(function () use ($cart, $user) {
$order = Order::create([
'user_id' => $user->id,
'total' => $cart->total(),
]);
foreach ($cart->items as $item) {
$item->product->decrement('stock', $item->qty);
$order->lines()->create($item->toArray());
}
return $order;
});
// If any exception is thrown, all changes are rolled backManual beginTransaction / commit / rollBack
Manual transaction control gives finer-grained handling, here ensuring both wallet operations either fully succeed or both revert.
DB::beginTransaction();
try {
Wallet::where('id', $from)->decrement('balance', $amount);
Wallet::where('id', $to)->increment('balance', $amount);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e; // re-throw so the caller knows it failed
}Nested transactions with savepoints
Laravel automatically creates SQL SAVEPOINTs for nested transaction calls, allowing inner transactions to fail independently of the outer one.
DB::transaction(function () {
Order::create([...]);
// Nested transaction becomes a savepoint
DB::transaction(function () {
// If this fails, only the savepoint is rolled back
// The outer order creation can still succeed
AuditLog::create([...]);
});
});
// Laravel uses SAVEPOINTs for nested transactions in MySQL/PostgreSQL
Discussion