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
Every level, savepoint and emitted statement runs on the tested reducer, matching Laravel's ManagesTransactions.
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
BEGINand the level becomes 1. Deeper, it raises the counter and sendsSAVEPOINT 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
ROLLBACKand discards every pending write. Deeper, it sendsROLLBACK 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 trans2a 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 TABLEorALTER TABLEcommits 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$attemptstimes. 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
- Laravel: database transactions is the manual, including the automatic deadlock retries and manual begin/commit.
- Laravel source: ManagesTransactions is the trait itself, where the level counter and the savepoint calls live.
- MySQL: SAVEPOINT and ROLLBACK TO SAVEPOINT is what those nested calls compile down to at the database.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.