All posts

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.

Marijan Kopčić••3 min read
Use Database Transactions for Atomic Operations

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.

Keep reading