0

What happens when you call DB::transaction()?

Advanced5 min read·eng-10-008
interviewsql

Concept

DB::transaction() in Laravel — what it actually does, step by step.

Under the hood:

  1. Calls $pdo->beginTransaction() — sets the connection to manual commit mode.
  2. Executes your callback.
  3. If the callback throws any exception: calls $pdo->rollBack(), re-throws the exception.
  4. 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
<?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