logo

Manual transactions don't fail the way you'd expect

The closure does more than clean up your code

Joel Clermont
Joel Clermont
2026-03-24

Laravel gives you two ways to wrap database operations in a transaction.

The first is the closure-based approach.

DB::transaction(function () {
    $order = Order::create($orderData);
    Payment::create(['order_id' => $order->id, 'amount' => $total]);
});

The second is the manual approach.

DB::beginTransaction();
$order = Order::create($orderData);
Payment::create(['order_id' => $order->id, 'amount' => $total]);
DB::commit();

They look equivalent, but they fail very differently.

With DB::transaction(), if anything inside the closure throws an exception, Laravel catches it, calls rollBack() for you, and re-throws the exception. Your database is back to a clean state.

The manual approach has no such safety net. If an exception is thrown between beginTransaction() and commit(), the transaction just stays open. Laravel doesn't know you intended to roll back.

Now imagine this code lives in a service class, and the calling code catches the exception to show a friendly error page or log the failure. The caller has no idea there's still an open transaction on the connection. Any database writes that happen after that point, like logging the error, recording a failed attempt, or sending a database notification, silently run inside the abandoned transaction.

Then at the end of the request, the database connection closes, and MySQL automatically rolls back any open transaction. Everything is gone. Not just the data from the failed operation, but all the "normal" work that happened after it too.

The closure-based approach avoids all of this. It's not just syntactic sugar. It's a meaningful safety difference.

If you do need the manual approach for some reason, always wrap it in try/catch with an explicit rollBack().

DB::beginTransaction();
try {
    $order = Order::create($data);
    Payment::create(['order_id' => $order->id, 'amount' => $total]);
    DB::commit();
} catch (Throwable $e) {
    DB::rollBack();
    throw $e;
}

But in most cases, DB::transaction() is the simpler and safer choice.

Here to help,

Joel

P.S. Subtle transaction bugs can hide in a codebase for months. A fresh perspective can catch them before they bite you. Schedule a code review.

Toss a coin in the jar if you found this helpful.
Want a tip like this in your inbox every weekday? Sign up below 👇🏼
email
No spam. Only real-world advice.