Use Database Transactions for Atomic Operations
When one business operation writes to several tables, either every write succeeds or none does. Here is how DB::transaction() keeps your data consistent.

Most business operations touch more than one table. An order needs an order row and a payment row. A transfer needs a debit and a credit. If the second write fails after the first one succeeded, your database now describes something that never happened. Database transactions make a group of writes succeed or fail as one unit.
Two writes, no guarantee
public function placeOrder(PlaceOrderRequest $request): Order
{
$order = Order::create($request->validated());
Payment::create(['order_id' => $order->id, 'amount' => $order->total]);
return $order;
}
If Payment::create() throws, because of a constraint, a timeout or a lost connection, the order row
stays behind. You now have an order with no payment, and some later report, job or support ticket
will have to deal with it.
Make it atomic
Laravel's DB::transaction() wraps the work in a transaction, commits it when the closure returns
and rolls it back if anything inside throws:
public function placeOrder(PlaceOrderRequest $request): Order
{
return DB::transaction(function () use ($request): Order {
$order = Order::create($request->validated());
$order->payment()->create(['amount' => $order->total]);
return $order;
});
}
Either both rows exist or neither does. The exception still reaches your handler, so the user sees an
error instead of a half-finished order. The manual beginTransaction(), commit() and rollBack()
calls are available too, but the closure form cannot forget the rollback.
Why this pays off
Consistent data by construction. You stop writing cleanup code for states that should never exist.
Simpler error handling. Inside the transaction you can just throw. The rollback is automatic.
Retries for deadlocks. DB::transaction($callback, attempts: 3) re-runs the closure when the
database reports a deadlock, which matters under real concurrent load.
Where transactions get tricky
A transaction only covers the database. If you send an email, charge a card through an API or dispatch a queued job inside the closure, that side effect happens even if the transaction later rolls back. Worse, a queued job can start running before the commit and not find the rows it needs.
Laravel has tools for exactly this:
ProcessOrder::dispatch($order)->afterCommit();
Queued jobs, events and notifications can all wait until the surrounding transaction has committed.
Also keep transactions short. A transaction holds locks for as long as it is open. Doing slow HTTP calls inside one blocks other requests that need the same rows.
A useful rule of thumb:
- Several writes that must all succeed → one
DB::transaction(). - Side effects outside the database → after the commit, with
afterCommit(). - Slow external calls → outside the transaction entirely.
The practical test
Find a controller or service method with two or more create(), update() or delete() calls in a
row. Ask what happens if the process dies between them. If the answer is "corrupted data", wrap them
in a transaction today.