So, you've built the feature. The controller works. The form submits. The user gets a nice success message.

Then production happens.

The payment provider gets slow. Email delivery takes three seconds. A CSV import locks up the request. A webhook fails halfway through. Suddenly that clean little feature feels like a shopping cart with one broken wheel.

That's where Laravel queues become the hidden backbone of scalable applications. They let your app say, "I got your request," then process slow or unreliable work safely in the background.

A Queue Is A Waiting Line For Work

A queue is not complicated as an idea. You put work into a line, and workers pull jobs from that line.

Think of it like a coffee shop. The cashier doesn't make every latte before taking the next order. They take the order, put it into the system, and the barista handles it. If the cashier did everything, the line would reach the parking lot.

Diagram of a coffee shop queue workflow mapped onto Laravel: customers place orders at the counter, orders enter a FIFO queue, barista workers process drinks in parallel from the queue, completed drinks go to the pickup shelf, and a separate retry station handles failed tickets.
Laravel queues, simplified: a coffee shop workflow with customers, orders, queue, workers, completed orders, and a retry station for failed tickets.

Laravel gives you a clean way to model that same pattern.

What Belongs In A Queue?

  1. Emails and notifications. Don't make users wait for SMTP, push services, or third-party APIs.
  2. Payment follow-up work. Receipts, analytics, fraud checks, and sync tasks can happen after the critical transaction.
  3. Imports and exports. Large CSV or report work should not live inside a web request.
  4. Webhook processing. Acknowledge the webhook quickly, then process the payload in a job.
  5. Expensive calculations. If it can run later without hurting the user experience, it's a queue candidate.

The rule is simple: if the user doesn't need the result immediately, strongly consider a job.

Jobs Make Background Work Explicit

In Laravel, a job is a class that represents one unit of work. That class is serializable, dispatchable, and handled by a queue worker.

Here's a small job that sends a welcome email:

PHP app/Jobs/SendWelcomeEmail.php
final class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(public int $userId) {}

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        Mail::to($user)->send(new WelcomeMail($user));
    }
}

Notice that the job stores the userId, not the whole business operation inside a controller. That makes the work explicit and testable.

You can dispatch it like this:

PHP app/Http/Controllers/RegisterUserController.php
$user = User::create($validated);

SendWelcomeEmail::dispatch($user->id);

return redirect()->route('dashboard');

The controller stays fast, and the email work moves to the background.

Retries Are A Feature, Not A Panic Button

Most queue failures are not dramatic code bugs. They're boring real-world failures.

A provider times out. A database connection drops. An API rate limit kicks in. A worker restarts during deployment. Production loves boring chaos.

Laravel lets jobs retry instead of failing immediately. That retry behavior is one of the biggest reasons queues are valuable.

Use Attempts And Backoff Intentionally

A retry without a strategy is just the same mistake repeated louder.

Here's a job with a retry limit and backoff schedule:

PHP app/Jobs/SyncCustomerToCrm.php
final class SyncCustomerToCrm implements ShouldQueue
{
    public int $tries = 5;

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

    public function handle(CrmClient $crm): void
    {
        $crm->syncCustomer($this->customerId);
    }
}

This job doesn't hammer the CRM every second. It slows down between attempts, which gives temporary problems time to recover.

Failed Jobs Are Signals

A failed job is not only an error. It's a signal that the system could not complete a promised unit of work.

That distinction matters. If a request fails, the user probably sees it. If a background job fails silently, nobody may know until a customer asks why their invoice, email, shipment, or subscription never updated.

That's why failed jobs need visibility.

What You Should Track

  1. Job class. Which kind of work is failing?
  2. Payload identity. Which user, order, tenant, or record is affected?
  3. Failure reason. Was it validation, timeout, rate limit, missing data, or a bug?
  4. Retry count. Did it fail once or repeatedly?
  5. Recovery path. Can you retry safely, or does a human need to fix data first?

A failed job table without process is like a smoke alarm with no batteries. It looks responsible, but it won't save you.

Horizon Makes Queues Observable

Laravel Horizon gives Redis queues a proper dashboard. You can see job throughput, failed jobs, supervisors, worker balancing, runtimes, and queue health.

For production applications, this is a big deal. Queue workers are long-running processes, and long-running processes need supervision.

A basic Horizon supervisor config might look like this:

PHP config/horizon.php
'production' => [
    'supervisor-1' => [
        'connection' => 'redis',
        'queue' => ['default', 'emails'],
        'balance' => 'auto',
        'maxProcesses' => 10,
        'tries' => 3,
    ],
],

The important idea is not the exact numbers. It's that queue capacity becomes a production setting, not a random command someone started in a terminal.

End-to-end diagram of a Laravel queue pipeline: a user request triggers an action, the application dispatches a job, the job lands in a Redis-backed queue with retry and idempotency markers, multiple workers pull jobs in parallel and call external services such as email, webhooks, and notifications, while Horizon shows real-time throughput, jobs per minute, queue health, and recent activity.
Laravel queues, end to end: dispatch, queue, workers, external services, success and failure paths — observed through Horizon.

Idempotency Is What Makes Retries Safe

Here's the part that separates beginner queue usage from production queue usage: idempotency.

An idempotent job can run more than once without creating duplicate damage. That's critical because jobs can be retried, workers can die, deployments can restart, and network calls can succeed even when your app thinks they failed.

Think of idempotency like checking whether the door is already locked before locking it again. The second attempt shouldn't break the lock.

A Non-Idempotent Job

This job can create duplicate charges if it runs twice:

PHP app/Jobs/ChargeCustomer.php
public function handle(PaymentGateway $gateway): void
{
    $order = Order::findOrFail($this->orderId);

    $gateway->charge($order->customer, $order->total);

    $order->markAsPaid();
}

The problem is that payment happens before the system proves whether this order was already charged.

A Safer Job

A safer version checks state and uses an idempotency key:

PHP app/Jobs/ChargeCustomer.php
public function handle(PaymentGateway $gateway): void
{
    $order = Order::findOrFail($this->orderId);

    if ($order->paid_at !== null) {
        return;
    }

    $gateway->charge(
        customer: $order->customer,
        amount: $order->total,
        idempotencyKey: 'order-' . $order->id,
    );

    $order->markAsPaid();
}

Now the retry behavior is much safer. The job has a memory of what it is trying to accomplish.

Common Queue Problems

  1. Dispatching jobs before the database transaction commits. The worker may run before the record exists.
  2. Passing huge models into jobs. Prefer stable identifiers and reload fresh data in handle().
  3. Retrying non-idempotent operations. This is how duplicate emails, charges, and side effects happen.
  4. Using one queue for everything. Separate urgent jobs from slow reports and bulk imports.
  5. Ignoring worker memory. Long-running workers need restarts, limits, and monitoring.

Queues are powerful, but they are not a trash bag for code you don't want to think about.

Pro Tips

  1. Name queues by priority or domain. payments, emails, imports, and webhooks are easier to reason about than one giant default queue.
  2. Set timeouts deliberately. A job timeout should be shorter than the worker timeout strategy around it.
  3. Make retries boring. Use backoff, idempotency keys, and state checks.
  4. Log business identifiers. Debugging Order 928431 failed is much better than debugging Job failed.
  5. Create a manual recovery path. Production needs retry buttons, dashboards, and operational habits.

Final Tips

One of the easiest production mistakes is treating queues as an optimization detail. I've done that before, and yeah, it usually becomes obvious only after the first ugly incident.

The better mental model is this: queues are part of your application's contract. When you dispatch a job, you're promising the user that something important will happen later.

Design your jobs like that promise matters. Because it does. Good luck with your workers 👊