log in
consulting hosting industries the daily tools about contact

Laravel Queued Jobs and DB Transactions: The Silent Corruption Zone

Database transactions in queued jobs don't behave the way most people expect, and the gap between what you think rolled back and what actually did can corrupt your data quietly.

Most developers assume that if a queued job throws an exception, the database rolls back cleanly. In my experience, that assumption is half right at best - and the half that's wrong is the half that corrupts your data without a single error log entry.

I hit this properly for the first time on a billing pipeline for an e-commerce client. Jobs were failing, being retried, and by the time I dug in, we had duplicate charges in one table and missing ledger entries in another. The job had partially committed on the first attempt before the exception fired. The retry then ran on top of that partial state. Fun times.

What Laravel's Job System Actually Does With Transactions

Laravel does not wrap your entire job in a database transaction by default. Full stop. When your handle() method throws, the queue worker catches the exception and either retries or marks the job failed - but anything already committed to the database stays committed.

If you manually open a transaction inside handle() and something throws before DB::commit(), that transaction does roll back correctly. That part works fine. The problem is the space between the transactions - the code that runs outside them.

Look at this common pattern:

public function handle(): void
{
    $order = Order::find($this->orderId);
    
    // This runs outside any transaction
    $charge = $this->paymentGateway->charge($order->total);
    
    // Now we open one
    DB::transaction(function () use ($order, $charge) {
        $order->update(['status' => 'paid', 'charge_id' => $charge->id]);
        
        Ledger::create([
            'order_id' => $order->id,
            'amount'   => $order->total,
            'type'     => 'charge',
        ]);
    });
    
    // This also runs outside any transaction
    $this->notificationService->sendReceipt($order);
}

If sendReceipt() throws, the job fails and retries. The charge happened. The database rows are committed. The retry fires $this->paymentGateway->charge() again on a paid order. You just double-charged a customer.

If instead the payment gateway throws after DB::transaction() somehow already committed - say you restructured the code - you now have ledger rows with no corresponding charge ID.

The corrupted state is always in the seams.

The after_commit Listener Problem

There's a second, sneakier version of this. If you dispatch jobs from within a transaction and your queue connection uses after_commit => true (or you've set it in the queue config), the job only dispatches once the outer transaction commits. That's the right behavior and it's good.

But if after_commit is false (the default for most drivers), the job can be dispatched and picked up by a worker before the transaction that created it has committed. The job runs, queries for the record, finds nothing, and either throws or silently does nothing. Then the outer transaction commits and you have a database row with no corresponding job work done, ever.

Here's how to check your queue config:

// config/queue.php - look at your active connection
'connections' => [
    'redis' => [
        'driver'       => 'redis',
        'connection'   => 'default',
        'queue'        => env('REDIS_QUEUE', 'default'),
        'retry_after'  => 90,
        'block_for'    => null,
        'after_commit' => true, // make sure this is true if you dispatch inside transactions
    ],
],

Or set it per-dispatch:

DB::transaction(function () use ($order) {
    $order->update(['status' => 'processing']);
    
    ProcessOrder::dispatch($order->id)->afterCommit();
});

The afterCommit() call on the job is the safer option because it's explicit at the call site. I'd rather see intent in the code than rely on a config value someone might change later.

The $this->tries and Partial State Problem

Here's the gotcha that bit me on that billing job. The job had $this->tries = 3. On attempt one, it charged the card, wrote the ledger rows, then threw trying to update a denormalized reporting table (schema mismatch I hadn't caught). Laravel retried it. Attempt two ran the full job again - charge, ledger, reporting table - and this time the reporting table write succeeded. Now I had two ledger rows and two charges.

The fix isn't just wrapping everything in a transaction. External side effects - API calls, emails, file writes - don't participate in database transactions. You cannot roll back a Stripe charge. You cannot un-send an email.

The pattern that actually works is idempotency at the job level:

public function handle(): void
{
    DB::transaction(function () {
        // Re-query inside the transaction with a lock
        $order = Order::lockForUpdate()->findOrFail($this->orderId);
        
        // Bail early if we've already done this work
        if ($order->status === 'paid') {
            return;
        }
        
        // Perform the side effect last, and only once
        $charge = $this->paymentGateway->charge($order->total);
        
        $order->update([
            'status'    => 'paid',
            'charge_id' => $charge->id,
        ]);
        
        Ledger::create([
            'order_id' => $order->id,
            'amount'   => $order->total,
            'type'     => 'charge',
        ]);
    });
}

Note that the payment gateway call is now inside the transaction. Yes, that holds a database lock while waiting on an HTTP call. For short-lived payment API calls (200-500ms) on low-contention rows, I've found this acceptable. If the charge succeeds but the commit fails due to a database error, you do have a charge with no corresponding record - but that's a different, more recoverable problem than a double charge, and you can reconcile it. You absolutely cannot un-charge a card.

For truly long-running external calls, you want an outbox pattern or at minimum a unique job constraint:

class ProcessOrderPayment implements ShouldQueue, ShouldBeUnique
{
    public int $tries = 3;
    
    public function uniqueId(): string
    {
        return 'order-payment-' . $this->orderId;
    }
    
    public function uniqueFor(): int
    {
        return 300; // 5 minutes
    }
}

This won't prevent all double-processing, but it prevents concurrent retries from stacking up and racing each other.

What Actually Rolls Back

To be completely explicit, here's what rolls back when a queued job fails:

  • Any open, uncommitted DB::transaction() block at the point of the exception: yes, rolls back.
  • Database writes that already committed before the exception: no, they stay.
  • External API calls (Stripe, SendGrid, your client's LIMS system): no, they are permanent.
  • File writes to disk or S3: no.
  • Cache writes: no.
  • Other dispatched jobs: depends on after_commit, but usually no - they're already in the queue.

The transaction scope inside a queued job is exactly the same as in a regular request. The job runner doesn't add any magic wrapper. When you see a job marked as failed in failed_jobs, the database is in whatever state it was when the exception fired.

When I Reach for Full Transaction Coverage

For jobs that are purely database work - data migrations, aggregations, denormalization, syncing records between two tables - I'll wrap the entire handle() method:

public function handle(): void
{
    DB::transaction(function () {
        // All database work, no external calls
        Product::where('sync_needed', true)
            ->chunkById(100, function ($products) {
                foreach ($products as $product) {
                    Inventory::updateOrCreate(
                        ['product_id' => $product->id],
                        ['quantity' => $product->stock_count]
                    );
                    
                    $product->update(['sync_needed' => false]);
                }
            });
    });
}

That's clean. Everything is in the transaction. If something throws mid-chunk, nothing commits and the job retries against a consistent state.

For jobs that mix database writes with external calls, I treat the external call as the point of no return. Everything before it should be validation and setup. Everything after it should be the write that records the side effect. And I write the idempotency check first, before I write anything else.

When I Wouldn't Trust a Job Transaction at All

For anything financial - charges, refunds, subscription changes - I don't rely on job transactions as the primary safety mechanism. I rely on the payment processor's idempotency keys, I store the external transaction ID before I do anything else, and I build reconciliation tooling that can detect and flag mismatches. The transaction is a secondary guardrail, not the primary one.

Same story for healthcare integrations. I had a FHIR integration for a clinic client where the job would write locally and then POST to the EHR. External system, no rollback possible. We ended up with an outbox table - write the pending event, commit, then a separate job processes the outbox and marks it sent. Two jobs, two responsibilities, no silent corruption.

The rule I've landed on: if the side effect can't be undone, the job design needs to account for that explicitly, not hope that a transaction wrapper handles it.

Queued jobs feel safe. They have retries, they have dead-letter queues, they have monitoring. But the retry mechanism assumes your job is safe to retry, and that assumption is only true if you designed for it. Most of the time, nobody does.

Related

Need help shipping something like this? Get in touch.