How I Use Queues and Jobs in Laravel - hero image illustrating production async work: small focused jobs, retry policies, idempotency guards, and queue monitoring

How I Use Queues and Jobs in Laravel (Without Overcomplicating Things)

Rahmat Ullah profile photoRahmat Ullah
8 min readLaravel, Queues, Async, Reliability

Jobs rarely fail in staging. They fail at scale. Queue bugs almost never show up in the PR review or the QA pass. They show up at 4 PM on a Wednesday, hours after the deploy, as duplicate invoices in a customer's inbox, a worker quietly stuck on a poisoned payload, or a backlog that nobody notices until support tickets start arriving. The job code looked clean. The local test passed. The production behavior is a different system.

This is the eleventh post in my Laravel series. I have written about mistakes I stopped making, scalable application structure, performance in production, API design, authentication and authorization, abstractions, third party integrations, production debugging, scalable database schema design, and long term maintainability. This one is about async work and how to keep background processing reliable without building unnecessary complexity.

The Real Problem

Most teams reach for queues because a request is too slow. The PDF takes four seconds to generate, the third party API is unpredictable, the welcome email occasionally times out. Push it to a queue, return a fast response, problem solved. That is the surface story, and it is the story most tutorials tell.

The expensive thing queues actually give you is a reliability boundary. Work no longer has to succeed inside one HTTP request. It can retry, it can be inspected, it can be replayed, it can be re-ordered. Performance is the cheap side effect. The boundary is the point.

But that boundary brings everything that comes with distributed work. Eventual consistency. Delayed failures that surface hours after the deploy. Workers that go offline silently. Backlogs that grow while the dashboard stays green. Stale serialized state. A job dispatched on Tuesday running on a worker container with a slightly different version of the code than the dispatcher had.

The line I keep coming back to is the one I tell new engineers on the team. Moving code to a queue does not remove complexity. It moves complexity. The synchronous version is something you can read top to bottom. The async version is a system.

Where Queue Architectures Go Wrong

Almost every queue related incident I have debugged falls into one of these patterns.

Dispatching everything because "async is faster"

Once ShouldQueue is in a codebase, every new feature finds a reason to use it. Five line database writes become jobs. Tiny string transformations become jobs. The HTTP response is faster, but the work has moved into a system that is harder to debug and test. The team has not gained anything except a longer worker log and a bigger surface area for bugs.

Heavy controller logic before dispatch

The controller does five hundred lines of work, then calls a job dispatch. The dispatch is fast. The endpoint is not. The team blames "the queue setup" for a slow endpoint that has nothing to do with queues. The slow part was the controller, and the fix lives in the structure post, not in the queue layer.

Unclear retry behavior

Laravel does not have a single "default retry" setting. The actual behavior is the combination of the worker's --tries and --backoff flags, the retry_after value in config/queue.php, the job's $tries and $backoff properties, and how the deploy actually starts the worker. On one project I inherited, the same job class retried three times in staging and indefinitely in production because the two environments started queue:work with different flags. Pin the behavior in code, on the job, where you can review it.

Jobs that are not idempotent

The single most common cause of "the customer got charged twice" tickets. A retry assumes the previous attempt did nothing. In any system with retries, that assumption has to be made true by the code, not hoped for.

Silent failures and no monitoring

Failed jobs land in failed_jobs and stay there. Nobody is paged. The first sign of a problem is a customer email three days later. Queue depth, age of the oldest waiting job, failure rate, retries per minute. None of these are visible. The first incident is also the first metric.

Stale serialized models

Dispatching an Eloquent model is supported, which makes SendInvoiceJob::dispatch($order) look elegant. But the worker picks up that job seconds, minutes, or hours later, and the model state on the wire is whatever it was at dispatch time. If anything else changed the row in the meantime, the job runs against stale data.

Large payloads inside jobs

A job that carries a 2 MB CSV row payload into Redis. Now the queue backend is the storage backend. Workers spend their time deserializing instead of doing work, and Redis memory creeps up until the next outage. Store the file, pass the ID.

Monster jobs and unreadable chains

One job that imports 500,000 rows, generates a PDF per row, reindexes search, and updates a dashboard. Forty minutes per run. When it fails on row 482,331, the retry starts from row one and holds a worker slot the whole time. The other shape this takes is a graph of jobs dispatching jobs dispatching jobs, with conditional branching that nobody on the team can fully draw on a whiteboard. Both fail the same way: small problems become large ones because the unit of work is wrong.

How I Use Queues in Production

A small number of rules that have held up across every Laravel app I have shipped or inherited.

Queue only what genuinely can wait

If the user is willing to wait for the result, do not queue it. If the user is fine knowing the result will arrive shortly, queue it. That keeps the queue list short and the system honest.

What I queue:

  • Imports and bulk row processing
  • Report and PDF generation
  • Search index updates and reindexing
  • Media processing (resize, transcode, thumbnailing, virus scan)
  • Outbound webhook delivery
  • Third party syncs (CRM, accounting, analytics)
  • Transactional emails and notifications
  • Inbound webhooks, where we acknowledge quickly and do the actual work asynchronously

What I leave synchronous:

  • Cheap local logic that fits inside a request
  • Validation and authorization
  • The small database write needed to produce the response
  • Anything the user needs visible on the very next page load

Keep jobs small

One responsibility per job, named like a verb. The moment you find yourself writing "and" in the job name, split it.

Bad:

ProcessEntireCustomerOnboardingJob

Better:

CreateCustomerAccountJob
SendWelcomeEmailJob
ReindexProductCatalogJob
GenerateOnboardingReportJob

Small jobs retry safely, fail in isolation, and tell you exactly what failed. They are also the only kind of job you can partially recover.

Prefer IDs over models for state-sensitive work

Dispatching an Eloquent model is fine for short lived, read-only work where the state at dispatch is also the state you want to act on. For anything state-sensitive (status flips, financial state, counters, anything another writer can touch between dispatch and execution), pass scalar IDs and re-fetch inside the job. The job then operates on current data instead of a snapshot.

class ReindexProductSearchJob implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $productId
    ) {}

    public function handle(SearchIndex $index): void
    {
        $product = Product::with('variants', 'tags')->findOrFail($this->productId);

        $index->upsert($product);
    }
}

Two upsides come along for free. The payload is tiny, so Redis memory stays flat. And if the row was deleted between dispatch and execution, findOrFail throws and the job fails cleanly instead of operating on phantom state.

Pick the right queue per workload

One Redis queue with everything on it is fine until one slow job class blocks every fast one. I split by latency expectation, not by feature. A fast queue for emails and webhook delivery. A heavy queue for imports and report generation. A thirdparty queue for outbound calls that need rate limiting. Worker counts and priority follow the queue, not the job class.

Retries, Failures, and Idempotency

This is the section that decides whether a queue setup is an operational benefit or a steady source of incidents.

Retries are not free

Every retry runs the job again, end to end, with no knowledge of what the previous attempt did. If the previous attempt pushed a row to a search index, the retry pushes it again. If it sent an email, the retry sends another one. If it called a downstream API that creates resources, the retry creates a duplicate. Some of those duplicates are harmless. Most are not.

Transient vs permanent failures

Not every failure should retry.

  • Transient failures deserve retries. A timeout, a momentary network blip, a database deadlock. The next attempt is likely to succeed.
  • Permanent failures should fail fast. A 422 from a downstream API because the payload is malformed. A "row does not exist" error. A schema mismatch.
class IndexProductBatchJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $maxExceptions = 3;

    public function backoff(): array
    {
        return [10, 30, 60, 300, 900];
    }

    public function handle(SearchIndex $index): void
    {
        try {
            $index->bulkUpsert($this->productIds);
        } catch (InvalidPayloadException $e) {
            // Permanent. Bad payload will not fix itself by retrying.
            $this->fail($e);
        }
    }
}

Exponential backoff matters because flat retries under load amplify whatever transient problem caused the first failure. Slowing them down gives the downstream system room to recover.

The failed_jobs table is a real queue

It is not an error log. It is work that did not complete and is sitting there waiting for somebody to look at it. Alert on its size, triage every entry, and either replay or delete with a written reason.

Idempotency is the contract

Any job that writes to a downstream system, sends a message, or changes state has to be safe to run twice. The pattern that has saved me the most is an atomic compare-and-swap on a status column, so only one attempt does the side effect.

class ChargeInvoiceJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(public int $invoiceId) {}

    public function handle(StripeClient $stripe): void
    {
        $claimed = DB::table('invoices')
            ->where('id', $this->invoiceId)
            ->where('status', 'pending')
            ->update(['status' => 'charging']);

        if ($claimed === 0) {
            return; // Another attempt got there first.
        }

        try {
            $charge = $stripe->charges()->create([
                'amount'          => $this->amountCents(),
                'currency'        => 'usd',
                'customer'        => $this->customerId(),
                'idempotency_key' => "invoice:{$this->invoiceId}",
            ]);

            Invoice::where('id', $this->invoiceId)->update([
                'status'           => 'paid',
                'stripe_charge_id' => $charge->id,
                'paid_at'          => now(),
            ]);
        } catch (\Throwable $e) {
            Invoice::where('id', $this->invoiceId)->update(['status' => 'pending']);
            throw $e;
        }
    }
}

Two guards stack. The atomic claim stops the job from doing the work twice locally. The Stripe idempotency key deduplicates on their side if the request reached them and the failure was on our end. Either one alone is fragile. Together they make duplicates effectively impossible.

The same shape applies wherever there is a side effect: store the inbound webhook event ID before processing, write a per-row status on imports so a restart skips what is already done, use deterministic external IDs so reindexing the same product twice produces the same result.

Timeouts and unique jobs

$timeout protects you from a worker stuck on a hung HTTP call. ShouldBeUnique protects you from dispatching the same logical job five times because two webhook deliveries arrived in the same second.

class GenerateMonthlyReportJob implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $timeout = 600;
    public int $uniqueFor = 3600;

    public function uniqueId(): string
    {
        return "report:{$this->tenantId}:{$this->month}";
    }
}

A Real Production Example

Invoice generation after a successful payment. The original controller did everything inline.

public function paymentSucceeded(Request $request)
{
    $order = Order::find($request->order_id);
    $order->markPaid();

    $pdf = app(InvoicePdfGenerator::class)->generate($order);
    Mail::to($order->customer)->send(new InvoiceMail($order, $pdf));
    app(QuickBooks::class)->syncInvoice($order);
    app(Hubspot::class)->notifyDealClosed($order);

    return response()->json(['status' => 'ok']);
}

The webhook took eight to twelve seconds to respond, which meant Stripe started retrying it. PDF generation occasionally failed under load. QuickBooks returned 502s once or twice a week. HubSpot was the least important call in the room and yet blocked everything.

The first refactor was obvious. Mark paid synchronously, dispatch the rest.

public function paymentSucceeded(Request $request)
{
    $order = Order::find($request->order_id);
    $order->markPaid();

    GenerateInvoiceJob::dispatch($order->id);
    SyncAccountingJob::dispatch($order->id);
    NotifyCrmJob::dispatch($order->id);

    return response()->json(['status' => 'ok']);
}

The endpoint went from twelve seconds to a hundred milliseconds. Stripe stopped retrying. Everyone declared victory.

Three weeks later, a customer received three identical invoice emails twenty minutes apart for the same order. GenerateInvoiceJob was occasionally failing at a downstream step after the email had already been sent. The default retry kicked in, the job ran again, and the email went out again.

The job mixed side effects with no guard, and an earlier attempt to "fix" it had wrapped the whole thing in DB::transaction(). That made the bug worse: the transaction could roll back after the SMTP server had already accepted the message, and the same email would go out on the next retry. Side effects on external systems do not roll back with the database. They have to live outside it.

The real fix was a status state machine, an atomic claim, and the email send placed firmly outside any database transaction:

public function handle(): void
{
    $order = Order::findOrFail($this->orderId);

    // Atomic claim. Only one attempt can flip pending -> sending.
    $claimed = DB::table('orders')
        ->where('id', $order->id)
        ->where('invoice_status', 'pending')
        ->update(['invoice_status' => 'sending']);

    if ($claimed === 0) {
        return; // Already in progress, or already sent.
    }

    try {
        $pdf = app(InvoicePdfGenerator::class)->generate($order);

        Mail::to($order->customer)->send(new InvoiceMail($order, $pdf));

        $order->update(['invoice_status' => 'sent']);
    } catch (\Throwable $e) {
        // Release the claim so a later attempt can pick it up.
        $order->update(['invoice_status' => 'pending']);
        throw $e;
    }
}

The claim is atomic at the SQL level, so two workers racing on the same order do not both pass. The email is sent outside any transaction, and the status that prevents a duplicate is persisted by the time the email goes out.

The lesson was not "be more careful." The moment the work moved to a queue, the surrounding code had taken on a new requirement that nobody had written down. The first refactor solved the symptom and shipped the bigger bug. The second refactor solved the actual problem.

What I Avoid Now

  • Giant god jobs that own a whole business flow in one handle() method.
  • Unset retry behavior. The exact policy belongs on the job, not on whatever flags the worker happens to start with.
  • Dispatching Eloquent models for state-sensitive work when an ID would do the same thing more safely.
  • External side effects (emails, HTTP calls, file writes) inside a database transaction. The transaction rolls back. The email does not.
  • Hidden job graphs, where an observer dispatches a job, that fires an event, that dispatches another job. The actual graph of work has to be visible from the dispatch site.
  • Queueing things that should stay synchronous, just because async sounds modern.
  • Zero queue monitoring. A queue that nobody watches is a queue that is failing silently.
  • Treating retries as harmless.

Key Principles I Follow

  • Queue for reliability boundaries, not fashion. The benefit is "this work can succeed even if the request did not."
  • Small jobs are easier to reason about. One verb per job. Split the moment "and" shows up in the name.
  • Retry behavior lives on the job. Tries, backoff, and permanent failure handling, all explicit.
  • Idempotency is the contract. Plan the guard before you ship the side effect.
  • Keep external side effects outside database transactions. Persist the claim first, then do the work.
  • Pass IDs for state-sensitive work. Re-fetch inside the job so it sees current data.
  • Monitor queues like production infrastructure. Depth, age of oldest job, failure rate.

Closing Thoughts

Queues are not about making code look scalable. They are about moving work safely. Good async architecture makes failures survivable. Bad async architecture creates invisible production bugs that surface hours after the deploy, in places nobody is looking.

The sticky note version: async work should make your system more resilient, not more mysterious. If you cannot point at every job in your codebase and say what happens when it runs twice, you do not have a queue setup yet. You have a deferred outage.

What is the worst queue incident you have had to debug?