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

Example · php
<?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.

Example · php
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 back

Manual beginTransaction / commit / rollBack

Manual transaction control gives finer-grained handling, here ensuring both wallet operations either fully succeed or both revert.

Example · php
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.

Example · php
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

  • Be the first to comment on this lesson.