Database & transactions

What a nested transaction actually rolls back

The mistake

It reads like each DB::transaction() is its own transaction. So a nested one that commits has saved its work, and a nested one that rolls back only undoes what happened inside it. Wrap a risky step in its own transaction, the thinking goes, and the outer work is safe from it either way.

Laravel does not nest transactions like that. There is only ever one real transaction with the database. Laravel counts how deep you are, and everything below the first level is a savepoint, not a transaction. An inner commit does not commit anything. Nothing is durable until the outermost commit runs, and if that one rolls back, every “committed” inner write goes with it.

The machine

Simulator · Laravel transactions

Every level, savepoint and emitted statement runs on the tested reducer, matching Laravel's ManagesTransactions.

Inner commit emits no SQL. It only drops the level 2 to 1. Nothing is durable yet.

Drive it

The panel boots mid-story: an outer transaction, a nested one, and an inner commit already done, so two writes sit pending. The big number is the transaction level, and the code chip is the SQL each action emits.

  • Press “Roll back the outer”. Both writes vanish, including the one the inner commit “kept”. Nothing was durable until the outer commit, so the rollback takes everything.
  • Reset, then press Commit. Now both writes land in the durable column together. The outermost commit is the only one that ever wrote them.
  • Watch the level and the SQL as you go. A nested Begin emits SAVEPOINT trans2. An inner Commit emits no SQL at all: it only lowers the level.

The mechanism

Laravel keeps a counter of how many transactions deep you are, and the counter decides what each call sends to the database.

  • Begin. At level 0 it sends a real BEGIN and the level becomes 1. Deeper, it raises the counter and sends SAVEPOINT trans{level} for the level it just reached, a named marker inside the one real transaction.
  • Commit. At level 1 it sends a real COMMIT, and every pending write becomes durable. At any deeper level it sends nothing. It just lowers the counter. This is the surprising part: an inner commit is not a commit, it is a decrement.
  • Rollback. At level 1 it sends a real ROLLBACK and discards every pending write. Deeper, it sends ROLLBACK TO SAVEPOINT trans{level}, which throws away the writes made after that savepoint and keeps everything before it.

So a nested rollback does behave the way people expect: it undoes the inner work and leaves the outer work intact. That is the half of the belief that holds. The half that fails is the commit. Because an inner commit writes nothing, durability always waits for the outermost commit. If an exception escapes the outer transaction after an inner block “succeeded”, the outer rollback erases that inner work along with the rest.

This is why DB::transaction() re-throws. When the closure throws, Laravel rolls back to the savepoint and lets the exception propagate. If you catch it around a nested call and carry on, the savepoint rollback is real and the inner write is gone. If you do not, it travels up and the outer transaction rolls back too.

In your code

DB::transaction(function () {
    User::create([...]);          // pending

    // a nested transaction is a savepoint, not a new transaction
    DB::transaction(function () {
        Order::create([...]);     // pending, marked by SAVEPOINT trans2
    });
    // that inner commit ran no SQL. The order is still only pending.

    throw new Exception('a later step failed');
    // the OUTER transaction rolls back, so the user AND the order are gone,
    // even though the inner transaction "completed".
});

When you really do want the outer work to survive a failed inner step, catch around the nested call. The savepoint rollback keeps the outer rows:

DB::beginTransaction();
User::create([...]);              // pending at level 1

try {
    DB::transaction(fn () => Order::create([...])); // savepoint
} catch (Throwable $e) {
    // the savepoint rolled back: the order is gone, the user stays
    report($e);
}

DB::commit();                     // now the user is durable

The fine print

  • Savepoints need driver support. MySQL, PostgreSQL, SQLite and SQL Server all have them. Laravel only emits a savepoint when the grammar reports support, so on a driver without it a nested begin does nothing special.
  • Savepoint names are reused, and a savepoint is a position, not a level. An inner commit releases nothing, so entering another nested transaction at the same depth sets SAVEPOINT trans2 a second time. Setting a savepoint whose name already exists replaces the old one, so the rollback now stops at the newer mark and the writes before it survive.
  • Some statements cause an implicit commit. On MySQL a DDL statement like CREATE TABLE or ALTER TABLE commits the open transaction on the spot, which a rollback then cannot undo.
  • DB::transaction($callback, $attempts) retries the whole closure on a deadlock, up to $attempts times. Make the closure safe to run more than once.
  • Code queued with afterCommit, and events that opt into it, fire only after the real outermost commit, never after an inner one.
  • This page caps the stack at three levels to stay readable. The real counter has no such limit.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.