What happens when you call DB::transaction()?
Concept
DB::transaction() in Laravel — what it actually does, step by step.
Under the hood:
- Calls
$pdo->beginTransaction()— sets the connection to manual commit mode. - Executes your callback.
- If the callback throws any exception: calls
$pdo->rollBack(), re-throws the exception. - If the callback completes without exception: calls
$pdo->commit().
Nested transactions: When DB::transaction() is called inside another DB::transaction(), Laravel doesn't actually nest real transactions (PDO doesn't support nested transactions). Instead, it uses savepoints: SAVEPOINT trans2, RELEASE SAVEPOINT trans2 on success, ROLLBACK TO SAVEPOINT trans2 on failure. Laravel tracks nesting level via a counter.
The $attempts parameter: DB::transaction($callback, 3) — retries the transaction up to 3 times if it fails due to a deadlock. On each retry, the callback is re-executed. This is critical: your callback must be idempotent (running it twice should be safe). Don't put Order::create() in a retried transaction if you haven't thought about duplicate creation.
DB::beginTransaction() / commit() / rollBack() manual control: When you need more control than the callback API provides. Use try/catch/finally.
Lock interactions: Transactions do NOT automatically lock rows. Use SELECT ... FOR UPDATE (Eloquent: ->lockForUpdate()) inside the transaction to prevent concurrent modifications.
Savepoint behavior: Laravel's $this->transactions counter on the database connection tracks nesting depth. Each nested beginTransaction() is actually SAVEPOINT sp_n. On rollback inside the inner transaction, it rolls back to the savepoint, not the entire outer transaction.
Code Example
<?php
// Basic DB::transaction() — auto-commit on success, auto-rollback on exception
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
$payment = Payment::create(['order_id' => $order->id, 'amount' => $order->total]);
Inventory::decrement($order->items);
return $order;
});
// If any line throws: rollback. If all succeed: commit.
// With retry on deadlock (callback must be safe to re-run)
$order = DB::transaction(function () use ($request) {
$order = Order::create($request->validated()); // be careful: if retried, creates duplicate!
return $order;
}, attempts: 3);
// Manual transaction — when you need more control
DB::beginTransaction();
try {
$sender = Account::where('id', $fromId)->lockForUpdate()->firstOrFail();
$receiver = Account::where('id', $toId)->lockForUpdate()->firstOrFail();
if ($sender->balance < $amount) {
DB::rollBack();
throw new InsufficientFundsException();
}
$sender->decrement('balance', $amount);
$receiver->increment('balance', $amount);
Transfer::create(['from' => $fromId, 'to' => $toId, 'amount' => $amount]);
DB::commit();
} catch (InsufficientFundsException $e) {
throw $e; // re-throw after rollback
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// Nested transactions — uses savepoints internally
DB::transaction(function () {
Order::create([...]); // this is in the outer transaction
try {
DB::transaction(function () { // SAVEPOINT created here
OrderItem::create([...]); // might fail
});
} catch (\Exception $e) {
// Inner transaction rolled back to savepoint
// Outer transaction is still running
\Log::warning('Item creation failed, order continues');
}
// Outer transaction commits
});
// Check transaction level
echo DB::transactionLevel(); // 0 = no transaction, 1 = one level, 2 = nested, etc.
// Listener — run code AFTER the transaction commits
DB::afterCommit(function () use ($order) {
SendOrderConfirmationEmail::dispatch($order); // only dispatch if transaction committed
});
// DB::afterCommit() ensures jobs aren't dispatched if the transaction later rolls back