Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 021 / 100

Database transactions in Laravel: lessons from production

Keep related writes consistent.

Expona Admin7 min read

Introduction

We've all shipped a payment service where half the order succeeded and half failed, leaving data in an inconsistent state. Database transactions in Laravel: lessons from production exist precisely to prevent this. Whether you're coordinating inventory updates with payment processing, or syncing user data across multiple tables, transactions ensure that either all writes succeed together or none of them do. This article walks through the patterns we've found work best in production systems handling real traffic.

The basics: DB::transaction()

Laravel's DB::transaction() is your primary tool for wrapping related database operations. It's straightforward: pass a closure and Laravel handles the commit or rollback for you.

php
DB::transaction(function () {
    $order = Order::create([
        'user_id' => $user->id,
        'total' => 99.99,
    ]);

    Inventory::where('product_id', $product->id)
        ->decrement('available_qty', 1);

    Payment::create([
        'order_id' => $order->id,
        'amount' => 99.99,
        'status' => 'pending',
    ]);
});

Everything inside the closure either completes successfully or rolls back entirely. No orphaned orders without inventory deductions, no payments floating without orders. In a real e-commerce system, this prevents the data corruption that would normally require manual intervention to clean up.

You can nest transactions using savepoints, which is particularly useful when you're calling multiple services that each use their own transactions internally:

php
DB::transaction(function () {
    $order = Order::create(['total' => 100]);

    DB::transaction(function () {
        // Inner transaction creates a savepoint
        InventoryService::reserve($order);
    });

    DB::transaction(function () {
        // Another savepoint
        PaymentService::charge($order);
    });
});

Laravel's transaction handling respects database-level nesting, so if an inner transaction fails, the outer one can still decide to continue or roll back.

Exceptions roll back automatically

One of Laravel's conveniences is that any exception thrown inside a transaction closure triggers an automatic rollback. You don't need to manually catch and undo anything—the framework handles it for you.

php
DB::transaction(function () {
    $order = Order::create([
        'user_id' => $user->id,
        'total' => 99.99,
    ]);

    // If this throws, the Order never gets created
    $this->validateInventory($product, 1);

    Inventory::where('product_id', $product->id)
        ->decrement('available_qty', 1);
});

If validateInventory() throws an exception, both the Order creation and any prior queries are rolled back. This automatic rollback extends to validation errors, API failures, or business logic exceptions—anything that throws ends the transaction cleanly.

We've found this pattern particularly valuable in payment processing pipelines. When external payment APIs fail, we want the entire transaction undone rather than leaving partial records scattered across multiple tables.

php
DB::transaction(function () {
    $order = Order::create(['total' => $total]);

    try {
        $response = PaymentGateway::charge($order->total);
    } catch (PaymentFailedException $e) {
        // Throwing here automatically rolls back the Order creation
        throw new OrderProcessingException('Payment declined', 0, $e);
    }

    Payment::create([
        'order_id' => $order->id,
        'gateway_id' => $response->id,
    ]);
});

The key lesson: let exceptions propagate. Don't swallow them with try-catch blocks inside your transactions unless you have a specific recovery path. The automatic rollback is a feature, not a bug.

Keep transactions short

The longer a transaction runs, the longer your database holds locks. In production systems with concurrent traffic, long locks cause contention and slow down other requests. We've learned to keep our transaction blocks as minimal as possible—load data beforehand, compute outside the transaction, then write only what's essential inside.

Here's an anti-pattern we see often:

php
// BAD: Long transaction with unnecessary work
DB::transaction(function () {
    $user = User::find($userId); // Lock acquired here
    
    // Sending emails inside transaction is slow
    Mail::send(new OrderConfirmation($order));
    
    // External API call inside transaction is slow
    $result = WebhookService::notify($user);
    
    // Generating PDFs inside transaction is slow
    $pdf = PDF::generate($order);
    
    $order->update(['confirmed_at' => now()]);
    // Lock held the entire time above
});

Every operation above adds latency to your transaction lock. In a busy system, this cascades into database connection pool exhaustion.

Instead, move work outside:

php
// GOOD: Minimal transaction, async work outside
$order = DB::transaction(function () {
    $order = Order::create([
        'user_id' => $userId,
        'total' => $total,
    ]);

    Inventory::where('product_id', $productId)
        ->decrement('available_qty', 1);

    return $order;
});

// Outside the transaction now
Mail::queue(new OrderConfirmation($order));
WebhookService::notifyAsync($order);
PDF::generateAsync($order);

The transaction completes in milliseconds. Everything else runs asynchronously without holding database locks. This is the pattern we use across trading systems, ride-hailing, and CRM platforms where request latency directly impacts user experience.

Dispatch jobs afterCommit

Here's a subtle but critical issue: if you dispatch a job inside a transaction and the transaction rolls back, your job still runs. This leads to processing phantom records that were never actually created.

php
// DANGEROUS: Job dispatches before commit
DB::transaction(function () {
    $order = Order::create(['total' => 100]);
    
    // This job might run before the Order is actually saved
    ProcessOrderJob::dispatch($order);
    
    throw new Exception('Something went wrong');
    // Transaction rolls back, but job already queued
});

By the time your job runs, the Order doesn't exist. You're processing a ghost.

Laravel 8+ provides afterCommit() to queue jobs only after the transaction completes successfully:

php
// CORRECT: Job dispatches only after commit
DB::transaction(function () {
    $order = Order::create(['total' => 100]);

    Inventory::decrement('qty', 1);

    // This job will only be queued if the transaction succeeds
    ProcessOrderJob::dispatch($order)->afterCommit();
});

We apply this pattern consistently: anything that modifies external state (jobs, webhooks, cache invalidation) goes in an afterCommit() callback. It guarantees your side effects only run when data is actually persisted.

php
DB::transaction(function () {
    $user = User::create([
        'email' => $email,
        'status' => 'pending',
    ]);

    // Only send verification email after user is saved
    Mail::send(new VerificationEmail($user))->afterCommit();

    // Only invalidate cache after data is committed
    Cache::forget('user_count')->afterCommit();

    // Only notify analytics after user exists
    AnalyticsService::track('user_created', $user)->afterCommit();
});

For older Laravel versions without afterCommit(), use DB::transaction()'s second parameter for callbacks:

php
DB::transaction(
    function () {
        $order = Order::create(['total' => 100]);
    },
    0, // Attempts
    function () {
        // Runs after commit
        ProcessOrderJob::dispatch($order);
    }
);

Common mistakes

Mixing synchronous and asynchronous operations: Trying to ensure consistency across both database writes and job processing requires deliberate design. We've seen systems where a payment succeeded but the fulfillment job never ran, or vice versa. Use afterCommit() for all async work tied to database transactions.

Catching exceptions too broadly: Swallowing exceptions inside a transaction prevents the rollback from firing. If your validation or payment call fails, let it throw. Only catch exceptions you can actually recover from.

Holding connections too long: Transactions acquire database locks. Running slow operations (API calls, file uploads, email sends) inside transactions blocks other queries. Always extract expensive work outside the transaction block.

Forgetting about transaction nesting: Laravel handles nested transactions via savepoints, but not all databases do equally well. PostgreSQL handles them cleanly; MySQL's behavior varies by storage engine. Test your nesting patterns against your specific database.

Not considering isolation levels: The default READ COMMITTED isolation level might not prevent race conditions in your specific scenario. In high-concurrency systems (trading, inventory), you might need SERIALIZABLE or application-level locking. Document your choice explicitly.

Key takeaways

  • Wrap related writes in DB::transaction() to ensure all-or-nothing consistency. If any operation fails, the entire block rolls back automatically.
  • Keep transactions as short as possible—move validation, external API calls, and heavy computation outside the transaction block to minimize lock duration.
  • Let exceptions propagate from within transactions. The automatic rollback is a feature. Only catch exceptions you can meaningfully recover from.
  • Always use afterCommit() when dispatching jobs or triggering side effects. This guarantees they only run after data is actually persisted.
  • Test your transaction behavior under load. Race conditions and lock contention reveal themselves at scale, not in development.
  • Be intentional about isolation levels. Document why you chose your level and test it against your database's actual behavior.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles