skip to content

In Laravel, how does DB::transaction() with a closure differ from DB::beginTransaction(), commit() and rollBack() when transferring loyalty points between two members?

level: middleimportance: must knowfreq 60%

answer

  1. closure: commit or rollback for you
  2. exception rolled back, then rethrown
  3. returns the closure's value
  4. manual: your own try/catch
  5. nested calls become savepoints

basics

~20 s

DB::transaction() begins, runs the closure, commits on success, and on any exception rolls back and rethrows, returning the closure's value. The manual trio leaves every path to you, so a missed rollBack() leaves the transaction open on the connection.

solid answer

~40 s

`DB::transaction(fn () => ...)` calls `beginTransaction()`, runs the closure, commits if it returns, and if **any** `Throwable` escapes it rolls back and rethrows; the closure's return value becomes the method's return value. For a points transfer that means the debit, the credit and the ledger row either all persist or none do, and a thrown `InsufficientPoints` exception reaches your controller. With `DB::beginTransaction()`, `DB::commit()` and `DB::rollBack()` you write that `try`/`catch` yourself; an early `return` or an uncaught path without `rollBack()` leaves the connection inside an open transaction, so later statements silently join it. Nesting works in both styles: an inner transaction becomes a **savepoint**, and only the outermost commit issues a real `COMMIT`.

code

php · 15 lines
php
<?php

use Illuminate\Support\Facades\DB;

// Bug: the early return skips commit(), leaving the transaction open
DB::beginTransaction();

$sender = DB::table('members')->where('id', $from)->first();
if ($sender->points < $points) {
    return back()->withErrors(['points' => 'Not enough points']);
}

DB::table('members')->where('id', $from)->decrement('points', $points);
DB::table('members')->where('id', $to)->increment('points', $points);
DB::commit();

go deeper

for a junior

Recall that DB::transaction() commits when the closure returns and rolls back when it throws, and that it returns the closure's value.

for a middle

Explain rethrowing, the savepoints Laravel uses for nested calls, and the paths a manual beginTransaction() must cover.

for a senior

Show how open or swallowed transactions corrupt state in practice and keep side effects and other connections out of the transactional closure.

for a principal

Set a convention for where transaction boundaries live in the codebase, such as action classes, and when the manual form is allowed.

## The scenario A coffee chain's loyalty app lets one member send points to another. Three writes must happen together: subtract points from the sender, add them to the receiver, and insert a row into a `point_transfers` ledger. If any step fails, none of them may persist, otherwise points are created or destroyed. ## The closure form ```php use Illuminate\Support\Facades\DB; $transfer = DB::transaction(function () use ($from, $to, $points) { $moved = DB::table('members') ->where('id', $from) ->where('points', '>=', $points) ->decrement('points', $points); if ($moved === 0) { throw new InsufficientPoints(); } DB::table('members')->where('id', $to)->increment('points', $points); return DB::table('point_transfers')->insertGetId([ 'from_member_id' => $from, 'to_member_id' => $to, 'points' => $points, ]); }); ``` What `DB::transaction()` does, step by step: 1. Calls `beginTransaction()` on the default connection. 2. Runs the closure, passing it the connection object. 3. If the closure returns, commits and returns the closure's value, here the new ledger id. 4. If any `Throwable` escapes, rolls back and **rethrows** it, so `InsufficientPoints` still reaches the controller or the exception handler. The docs summarise it as: you do not need to worry about manually rolling back or committing. ## The manual form ```php DB::beginTransaction(); try { // the same three writes DB::commit(); } catch (\Throwable $e) { DB::rollBack(); throw $e; } ``` This is equivalent only when you write every path correctly. Common mistakes: - **An early `return`** inside the `try` block that skips `commit()` leaves the transaction open. - **Catching the exception and not rethrowing** hides the failure from the caller. - **Catching only `Exception`** misses PHP `Error` instances such as a `TypeError`, so the transaction stays open. An open transaction does not close by itself while the connection lives. Every later statement on that connection joins it, and nothing is committed until something calls `commit()`. The manual form earns its place only when the begin and the end genuinely live in different places, such as a framework hook or a long import that commits in batches. ## Nesting and savepoints Laravel counts transaction levels per connection (`DB::transactionLevel()`): | Call | Level | SQL sent | |---|---|---| | outer `beginTransaction()` | 0 → 1 | `BEGIN` | | inner `beginTransaction()` | 1 → 2 | `SAVEPOINT trans2` | | inner `rollBack()` | 2 → 1 | `ROLLBACK TO SAVEPOINT trans2` | | inner `commit()` | 2 → 1 | nothing | | outer `commit()` | 1 → 0 | `COMMIT` | So a service method that wraps its own work in `DB::transaction()` can be called from inside another transaction safely: its work becomes part of the outer one and is only durable when the outermost level commits. ## Choosing between the two forms - **Default to the closure.** It cannot forget a rollback, it rethrows for you, and it returns a value, which keeps the transfer in one expression. - **Use the manual form** only when the begin and the end cannot share a closure, and then put `commit()` and `rollBack()` in a `try`/`catch (\Throwable $e)` that rethrows. - **Keep the closure short.** Everything inside it holds the transaction open, and with it any row locks the writes took; slow work there delays every other transfer touching the same members. ## Things that stay outside the transaction - **Other connections.** The transaction covers one connection; `DB::connection('legacy')` writes are not part of it. - **Side effects.** Mail, HTTP calls and cache writes inside the closure happen immediately and are not undone by a rollback. Defer them until after commit. - **Reads routing.** While a transaction is open, reads on that connection use the write connection, so a read inside the closure sees the transaction's own writes.

  • Inside a DB::transaction() closure you catch an exception from one write and continue. What gets committed?
    Everything that succeeded. `DB::transaction()` only rolls back when a `Throwable` escapes the closure; an exception you catch and swallow looks like success, so the closure returns and the transaction commits. If one failed write must undo the others, let the exception propagate, or rethrow it after logging.
  • What does DB::transactionLevel() tell you, and when is it useful?
    It returns how many nested transaction levels are open on that connection: 0 outside any transaction, 1 inside the outermost, 2 or more inside savepoints. It helps assert in a service that a caller has already opened a transaction, or debug a manual transaction that was never closed.

saying these in an interview costs you the question

  • Believes DB::transaction() swallows the exception after rolling back
  • Thinks a nested DB::transaction() commits independently of the outer one
  • Says the manual form rolls back automatically on an exception
  • Expects a rollback to undo mail sent inside the closure
  • Catches only Exception around manual commit and rollBack