Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Database Transactions in Laravel: When You Need Them and When You Don't

About Post

A tenant pays rent. Your code creates a payment record, reduces the invoice balance, and writes a ledger entry. Three queries. The first two succeed, then the third fails because of a typo in a column name that only that code path hits.

Now the money is recorded, the balance is reduced, and the ledger has no idea any of it happened. Someone in finance will find that mismatch weeks later, and nobody will enjoy the afternoon that follows.

That's the problem database transactions solve. But transactions are also one of those tools people either never use or wrap around everything. Let's look at when you need them, when you don't, and the one trap that catches almost everyone: side effects.

What a transaction gives you

A transaction groups several queries into one unit: either all of them are saved, or none of them are. If anything fails in the middle, the database rolls back to how it was before you started.

In Laravel, the cleanest way to use one is the closure form:

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($invoice, $amount) {
    $invoice->payments()->create(['amount' => $amount]);
    $invoice->decrement('balance', $amount);
    LedgerEntry::create([
        'invoice_id' => $invoice->id,
        'amount'     => $amount,
        'type'       => 'rent_payment',
    ]);
});

If the closure finishes, Laravel commits. If it throws any exception, Laravel rolls back and re-throws the exception so you still see the error. Whatever the closure returns, DB::transaction() returns too, which is handy for passing back the created model.

You can also call DB::beginTransaction(), DB::commit() and DB::rollBack() yourself, but the closure is harder to get wrong. With the manual version, it's very easy to forget the rollback in one catch branch.

When you need one

My simple test: would it be a problem if only some of these writes happened? If yes, use a transaction.

  • Money moving: payments, refunds, wallet balances, invoice totals.
  • Parent and children together: an order and its line items, a contract and its payment schedule.
  • Read-then-write decisions: "if the balance is enough, deduct it" (more on this below).
  • Moving something between states: marking a unit as leased while creating the lease.

When you don't

  • A single query. One INSERT or UPDATE is already atomic on its own.
  • Read-only code. There's nothing to roll back.
  • Writes that are fine independently. Logging a page view and updating a "last seen" timestamp don't need to succeed together.
  • The whole request "just in case". Long transactions hold locks longer, which makes other requests wait and makes deadlocks more likely. Keep transactions as short as the work that truly belongs together.

Transactions don't stop race conditions by themselves

This surprises people. Two requests can both read the same balance, both decide there's enough, and both deduct it, each inside its own perfectly valid transaction.

When a decision depends on what you just read, lock the row while you decide:

$payment = DB::transaction(function () use ($invoiceId, $amount) {
    $invoice = Invoice::whereKey($invoiceId)->lockForUpdate()->firstOrFail();

    if ($amount > $invoice->balance) {
        throw new OverpaymentException();
    }

    $invoice->decrement('balance', $amount);

    return $invoice->payments()->create(['amount' => $amount]);
}, 3);

lockForUpdate() makes the second request wait until the first transaction finishes, so it reads the updated balance. The lock only works inside a transaction, which is one more reason they go together.

That second argument, 3, is the number of attempts. If the database reports a deadlock, Laravel retries the whole closure up to that many times before giving up. Locking code makes deadlocks more likely under load, so a small retry count is a cheap safety net. Just make sure everything inside the closure is safe to run again, which leads to the real trap.

The trap: side effects inside the transaction

The database can roll back. Your email server can't. Neither can your queue, a payment gateway, or an SMS provider.

Look at what goes wrong here:

DB::transaction(function () use ($contract) {
    $contract->update(['status' => 'signed']);
    Mail::to($contract->tenant)->send(new ContractSigned($contract));
    GenerateContractPdf::dispatch($contract);
    $contract->schedule()->createMany($this->installments($contract));
});

Two different bugs are hiding in it:

  1. If createMany fails, the transaction rolls back, but the tenant has already received an email saying their contract is signed. You can't unsend it.
  2. Even when everything succeeds, the queued job might be picked up by a worker before the transaction commits. The worker queries the database, doesn't see the signed contract yet, and fails or processes stale data. This one is intermittent, which makes it miserable to debug.

The fix: run side effects after commit

Laravel has first-class support for this:

DB::transaction(function () use ($contract) {
    $contract->update(['status' => 'signed']);
    $contract->schedule()->createMany($this->installments($contract));

    GenerateContractPdf::dispatch($contract)->afterCommit();

    DB::afterCommit(fn () => Mail::to($contract->tenant)
        ->queue(new ContractSigned($contract)));
});
  • ->afterCommit() on a dispatch holds the job until the transaction commits. If it rolls back, the job is never dispatched.
  • DB::afterCommit() does the same for any callback. Outside a transaction, it simply runs immediately.
  • You can make this the default for a queue connection by setting 'after_commit' => true in config/queue.php. Queued event listeners can implement ShouldQueueAfterCommit for the same behaviour.

And for calls to external APIs, like charging a card? Don't put them inside the transaction at all. A charge can't be rolled back by your database, and a slow gateway would hold your row locks for the whole round trip. Record the intent, call the gateway outside, then record the result in a second short transaction.

The rule: inside a transaction, only touch the database. Everything that leaves your database (emails, jobs, HTTP calls, files) happens after commit.

Two small details worth knowing

  • Nested transactions work in Laravel: an inner DB::transaction() uses a savepoint, so if you catch the inner exception, only the inner work is rolled back and the outer transaction can carry on.
  • Engine matters. In MySQL, transactions need InnoDB. Old MyISAM tables silently ignore them, which is a lovely surprise in a legacy database.

The short checklist

  • Several writes that must succeed together? Transaction.
  • Decision based on a value you just read? Transaction plus lockForUpdate().
  • Emails, jobs, events, API calls? After commit.
  • Keep it short, and retry on deadlocks.

The Laravel database transactions docs are short and worth a read once.

Have you ever been bitten by a job running before its transaction committed? How long did it take you to work out what was going on?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close