Database Transactions in Laravel: When They Silently Do Nothing
Database Transactions in Laravel: When They Silently Do Nothing

A transaction is a promise: everything inside it happens, or none of it does. Laravel makes it look like one line, which is part of the problem — there are several ways to write code that appears transactional and is not, and every one of them fails only under conditions you did not test.
This is where the guarantee actually breaks.

The basic shape
<?php
DB::transaction(function () use ($request) {
$invoice = Invoice::create([...]);
$invoice->lines()->createMany($request->lines);
$invoice->project->update(['invoiced_at' => now()]);
});
If anything inside throws, everything rolls back. That much is true and works. The problems start when something inside the closure is not a database write, or when the exception never reaches the outside.
The exception you caught
The most common failure, and the most innocent-looking.
<?php
// broken: the transaction has nothing to react to
DB::transaction(function () {
$order = Order::create([...]);
try {
$this->payments->charge($order);
} catch (PaymentFailed $e) {
Log::error('payment failed', ['order' => $order->id]);
// swallowed - so the transaction commits happily
}
$order->update(['status' => 'paid']);
});
The payment failed, was logged, and the order is now marked paid. The transaction did exactly what it was told: nothing propagated out of the closure, so there was nothing to roll back.
A transaction rolls back on an exception leaving the closure. Catching an exception inside it is telling it that everything is fine. If you must catch, re-throw, or call DB::rollBack() explicitly and return.
DDL commits behind your back
This one is genuinely surprising the first time. In MySQL, most schema statements cause an implicit commit. The transaction you thought you were inside has already ended.
<?php
DB::transaction(function () {
DB::table('reports')->insert([...]);
// MySQL commits everything above, right here
DB::statement('CREATE TEMPORARY TABLE tmp_report AS SELECT ...');
throw new RuntimeException('too late'); // the insert is already committed
});
CREATE, ALTER, DROP, TRUNCATE and several others all do this. It matters most in two places people do not expect: a migration that mixes schema changes with data changes inside a transaction, and reporting code that builds temporary tables.
PostgreSQL is different — DDL is transactional there, which is one of the genuine reasons teams prefer it for migrations. If you are on MySQL, keep schema changes and data changes in separate steps and do not assume the wrapper is protecting you.
Queued jobs that run before the commit
This causes a particular kind of intermittent bug that is very hard to reproduce, because it depends on timing.
<?php
DB::transaction(function () use ($data) {
$invoice = Invoice::create($data);
SendInvoiceEmail::dispatch($invoice); // goes onto the queue immediately
$this->recalculate($invoice); // throws
});
The job was pushed to Redis the moment dispatch was called, not when the transaction committed. A worker can pick it up within milliseconds, query for the invoice, and find nothing — because the transaction has not committed, and may be about to roll back entirely.
The symptom is a job that fails with “model not found” occasionally, on a busy system, and never in development where the worker is slower than the request.
<?php
// the fix, per dispatch
SendInvoiceEmail::dispatch($invoice)->afterCommit();
// or globally, in config/queue.php on the connection
'after_commit' => true,
Turn it on globally. The cases where you genuinely want a job dispatched before the commit are rare and can opt out with beforeCommit(). The default the other way round produces bugs that take a week to find.
The same trap applies to model events, broadcasts and anything else that reacts to a change — if the reaction reads from the database, it has to happen after the commit, not after the write.

Anything that is not the database does not roll back
This is the general form of the previous two, and it is worth stating as a rule because it covers the cases not listed here.
- A file written to disk stays written.
- An email sent cannot be unsent.
- An HTTP call to a payment gateway has already charged the card.
- A cache entry updated is now inconsistent with a database that rolled back.
- A file uploaded to object storage is still there.
The rule: a transaction should contain database writes and nothing else. Everything with an external effect happens after the commit, or is made undoable, or is made idempotent so that re-running the whole operation is safe.
Where an external call genuinely must be part of the operation — charging a card and recording the charge — the answer is not a bigger transaction. It is to record the intent first, make the call outside the transaction, and record the outcome in a second short transaction, with a reconciliation job for the cases that end up stuck in between. That is more work and it is the only version that survives the gateway timing out.

Read consistency inside a transaction
A detail that matters for reports and for anything that reads several tables and expects them to agree.
MySQL’s default isolation level is REPEATABLE READ. Inside a transaction, the first read establishes a snapshot, and subsequent reads of the same rows see that snapshot even if another transaction has committed changes in the meantime. That is usually what you want — a report built from six queries sees one consistent picture rather than six different moments.
The surprise is the other direction. Code that opens a long transaction and expects to see other people’s recent commits will not see them. A worker that starts a transaction, waits on something slow, and then reads is reading the world as it was when it started.
Two practical consequences. Keep read transactions short, for the same reason as write ones. And if you genuinely need the latest committed value inside a transaction — checking whether a row still exists before acting — a locking read forces a fresh look rather than the snapshot.
Transactions across two databases
Worth saying plainly because people reach for it: there is no practical way to make a write to your database and a write to a different system atomic. Distributed transactions exist, are supported poorly, and are avoided by almost everyone for good reasons.
The pattern that works instead is the outbox. Inside the transaction, write the intent to a table in your own database — alongside the data, atomically, since it is the same database. After the commit, a worker reads the outbox and performs the external effect, marking each row as it succeeds.
<?php
DB::transaction(function () use ($invoice) {
$invoice->save();
Outbox::create([
'event' => 'invoice.created',
'payload' => ['invoice_id' => $invoice->id],
]); // same database, same transaction, atomic
});
// a worker, after the commit, delivers and marks it done
Either both the invoice and the intent exist, or neither does. The external effect may be delayed, and it may happen more than once if the worker crashes between delivering and marking — which is exactly why the receiving side has to be idempotent. That is the trade: you give up “exactly once” and get a guarantee that nothing is silently lost.
Nested transactions are not transactions
Laravel supports nesting, and it is worth knowing exactly what it does, because the mental model most people have is wrong.
<?php
DB::transaction(function () { // real transaction
Order::create([...]);
DB::transaction(function () { // SAVEPOINT, not a transaction
Payment::create([...]);
});
});
Only the outermost is a real transaction. Inner ones become savepoints. The practical consequences:
- An inner rollback returns to the savepoint; the outer transaction continues.
- An inner commit commits nothing — the outer transaction still decides.
- A service method that opens its own transaction behaves differently depending on whether its caller opened one.
That last point is the one that causes real bugs. A method that is safe called directly can be unsafe called from inside another transaction, and vice versa. Either write services that never open transactions and let the caller decide, or be explicit about which methods are transaction boundaries — and write it in the method name or the docblock.
Deadlocks, and retrying them
Two transactions touching the same rows in different orders will occasionally deadlock. The database picks one and kills it, and your code receives an exception.
This is normal, not a bug to be eliminated, and the right response is to retry:
<?php
DB::transaction(function () {
// ...
}, 3); // Laravel will retry up to three times on a deadlock
Two things make deadlocks rarer. Acquire rows in a consistent order — if one code path locks the project then the invoice and another does the reverse, you have built one. And keep transactions short, because the window in which a conflict is possible is exactly the duration of the transaction.
A retry is only safe if the closure is idempotent, which is another reason to keep external effects out of it. Retrying a transaction that sends an email sends two.
Locking a row you intend to change
The classic mistake in anything involving a balance or a counter:
<?php
// broken under concurrency: two requests read the same value
$account = Account::find($id);
$account->balance -= 100;
$account->save();
// correct: lock the row for the duration of the transaction
DB::transaction(function () use ($id) {
$account = Account::lockForUpdate()->find($id);
$account->balance -= 100;
$account->save();
});
Without the lock, two concurrent requests both read 500, both write 400, and one deduction disappears. lockForUpdate() makes the second wait until the first commits.
Where the operation is a simple arithmetic change, an atomic update is simpler and does not need the lock at all — DB::table(...)->decrement('balance', 100) is a single statement and cannot interleave.
Where to put the boundary
A question that decides how much of this you ever have to think about: which layer opens transactions?
Three conventions, and any of them works as long as the team picks one.
- In the controller. Easy to see, and it tends to wrap too much — validation, external calls, response building — which is how non-database work ends up inside a transaction.
- In an action or service class that represents one use case. Our preference. The boundary is the operation, which is exactly what atomicity is about, and the class name says what is being made atomic.
- In the repository or model layer. Tempting and usually wrong — it makes each individual write atomic, which the database already guarantees, while the multi-write operation that actually needed it is left unprotected.
Whichever you choose, write it down and make it visible in the code. A codebase where transactions appear at three different layers is one where nobody can tell, at a glance, whether a given piece of work is inside one — and that uncertainty is what produces the nested-transaction surprises above.
Reading the log when it goes wrong
Two habits make transactional bugs diagnosable after the fact rather than reproducible-only.
Log the rollback. Laravel does not by default. A short listener that records every rollback with the exception that caused it turns a silent “the record did not save” report into a line naming the failure. Most of these bugs are only mysterious because the failure is invisible.
Log transaction duration for anything slow. A transaction open for four seconds is a lock held for four seconds, which is a deadlock and a timeout waiting to happen elsewhere. Alerting on long transactions finds the problem before it finds your users, and the usual culprit is an HTTP call that somebody put inside one.
Testing that it works
Transactional bugs pass tests that use a single request and a fast database. Three tests that find real problems:
- Force a failure at the end. Throw deliberately as the last statement in the closure and assert that nothing was written — including the rows created early, and including that no job was queued.
- Assert the job queue is empty after a rolled-back transaction. This catches the missing
afterCommitdirectly, which no other test will. - Run the same operation concurrently. Two processes hitting the same endpoint at once is how you find the missing lock, and it is the only way to find it.
Be careful with test suites that wrap every test in a transaction for cleanup — the common default. Inside that wrapper your application’s transactions become savepoints, and
afterCommitcallbacks may never fire. Laravel provides a way to make them run in tests; without it, a test asserting that a job was dispatched after commit passes for the wrong reason, or fails for one.
The second thing worth auditing is any transaction closure containing an HTTP call. Those are the ones that hold a lock for the duration of somebody else’s network, and they are what turn a slow third-party into a database incident on your side.
The short version
- A transaction rolls back on an exception leaving the closure. Catching inside it means committing.
- DDL in MySQL commits implicitly. Do not mix schema and data.
- Dispatch jobs with
afterCommit— set it globally. - Files, emails, API calls and cache writes do not roll back. Keep them outside.
- Nested transactions are savepoints; only the outermost is real.
- Retry on deadlock, and keep transactions short and consistently ordered.
- Lock rows you intend to modify, or use an atomic update.
- Test the rollback path, including that no job was queued.
If you audit one thing after reading this, grep for dispatch( inside a transaction closure and check whether after_commit is on. That single setting accounts for more intermittent production bugs in Laravel applications than the rest of this page combined, and it is one line of configuration.
Almost all of these come from the same root: DB::transaction protects database writes, and modern application code does a great deal that is not a database write. The guarantee is real and it is narrower than it looks.

