Reader Stacks

Database Transactions in Laravel

Any operation touching more than one table — where a partial failure would leave the data in a genuinely inconsistent state — belongs inside a transaction, not left to run as separate, independent queries.

A transaction groups multiple database operations so they succeed or fail as one unit — if anything inside the transaction throws an exception, every change made so far within it is rolled back, as if none of it had happened. Without a transaction, a failure partway through a multi-step operation can leave the database in a state no single, correct outcome would ever produce.

1. A concrete example: transferring money between two accounts

// Without a transaction — a real risk
$fromAccount->decrement('balance', $amount);
// if the app crashes or an exception is thrown right here...
$toAccount->increment('balance', $amount);
// ...money has vanished: debited from one account, never credited to the other

2. The fix: DB::transaction()

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($fromAccount, $toAccount, $amount) {
    $fromAccount->decrement('balance', $amount);
    $toAccount->increment('balance', $amount);
});

If any exception is thrown anywhere inside the closure, Laravel automatically rolls back every database change made within it — the two accounts return to their original balances, exactly as if the transfer had never been attempted, rather than being left half-completed.

3. Manual transaction control

DB::beginTransaction();

try {
    $fromAccount->decrement('balance', $amount);
    $toAccount->increment('balance', $amount);

    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

DB::transaction() is the simpler, preferred form for most cases — it handles the commit/rollback logic automatically. Manual beginTransaction()/commit()/rollBack() is worth reaching for only when the commit needs to happen conditionally, based on logic more complex than "did an exception get thrown."

4. Automatic retries on deadlock

DB::transaction(function () {
    // ...
}, attempts: 3);

A deadlock (two transactions each waiting on a lock the other holds) is a normal, if uncommon, occurrence under real concurrent load — passing an attempts count tells Laravel to automatically retry the entire transaction closure that many times if a deadlock exception occurs, rather than failing immediately on the first collision.

5. What belongs inside a transaction — and what shouldn't

Anything genuinely multi-step where a partial completion would be a real, inconsistent data problem: creating an order alongside decrementing inventory, transferring funds, or any operation across more than one related table that must stay consistent. A transaction should not wrap slow, non-database operations — an API call to a third-party service or sending an email, for example — since those don't participate in rollback at all, and holding a database transaction open for their full duration needlessly extends how long any locks involved are held.

6. Nested transactions

DB::transaction(function () {
    // outer transaction
    DB::transaction(function () {
        // nested — Laravel uses savepoints here
    });
});

Laravel supports nesting DB::transaction() calls using database savepoints — an inner transaction can be rolled back to its savepoint independently in some scenarios, but the outer transaction ultimately still governs whether everything commits; a rollback anywhere in the chain still rolls back the outer transaction as a whole.

Topics: Database Queries & Eloquent