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.