Stop Blocking Your Users: Laravel Queues Explained with a Real Example

Stop Blocking Your Users: Laravel Queues Explained with a Real Example

Introduction

Most “slow Laravel apps” are not short on server power. They are busy doing the wrong work in the wrong place.

A checkout that sends an invoice email, builds a PDF, and calls an accounting API before redirecting can easily take several seconds. The order was already saved, and the user was already done, yet the request kept working anyway.

Laravel Queues exist to solve that problem: keep the HTTP response lightweight and move time-consuming tasks into background jobs. This piece walks through why that split matters, how the pieces fit together, and what the code is actually doing in a real invoice-email flow.

What Laravel Queues change

A queue is simply deferred work. Your app places a background job on a list, and a worker process picks it up later and runs it.

That may sound like a small change, but in practice it completely changes how you structure your controllers. The request becomes responsible for decisions and persistence. The background job becomes responsible for side effects—mail, files, webhooks, and slow integrations.

A useful rule: if the user does not need the result to see the next screen, it probably should not block the response.

Common tasks that are well suited for queues include:

  • Emails and SMS
  • PDF and report generation
  • Image processing
  • Third-party API calls
  • Imports, exports, and cleanup

Tasks that should usually not be queued include: validation, authentication, authorization, and any payment confirmation the success page depends on. Those still belong in the request.

The cost of doing everything inline

public function store(OrderRequest $request)
{
    $order = Order::create($request->validated());

    Mail::to($order->customer_email)->send(new InvoiceMail($order));
    $order->generatePdf();
    $this->notifyAccounting($order);

    return redirect()->route('orders.show', $order);
}

Order::create() is fine here—the success page needs a real order. Everything after it is optional from the user’s point of view, yet the browser still waits for all of it.

Latency adds up, and failures become harder to manage. If SMTP times out after the order is saved, the user may see an error for work that already succeeded. You end up with a paid order and a confused customer.

A cleaner approach is to let the request do only what it needs to do:

public function store(OrderRequest $request)
{
    $order = Order::create($request->validated());

    SendInvoiceEmail::dispatch($order);

    return redirect()->route('orders.show', $order);
}

Calling dispatch() does not send the email immediately. It records a background job for a worker. The redirect can return while the email is still pending. That is the whole point.

Choosing where background jobs live

In .env:

QUEUE_CONNECTION=database

The Database driver is a solid default while you are learning the flow or shipping a small app. Under real traffic, Redis is usually the better choice:

QUEUE_CONNECTION=redis

The sync driver can be misleading. It keeps the job syntax but runs everything inside the request, so you get none of the performance benefit.

With the Database driver, you also need storage for pending and failed work:

php artisan queue:table
php artisan queue:failed-table
php artisan migrate

The jobs table holds what still needs to run. The failed_jobs table holds what gave up after retries. Without those tables, Database queues have nowhere to write.

The job itself

php artisan make:job SendInvoiceEmail
<?php

namespace App\Jobs;

use App\Mail\InvoiceMail;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class SendInvoiceEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(public Order $order)
    {
    }

    public function handle(): void
    {
        Mail::to($this->order->customer_email)
            ->send(new InvoiceMail($this->order));
    }
}

ShouldQueue is the switch. With it, Laravel pushes the background job onto the queue. Without it, the same dispatch path tends to run immediately.

The traits are less mysterious than they look:

  • Dispatchable gives you SendInvoiceEmail::dispatch()
  • Queueable gives you ->delay() and ->onQueue()
  • InteractsWithQueue gives you controls like $this->fail()
  • SerializesModels stores the order ID, then reloads a fresh model when the worker runs

That last one matters. The worker should not depend on an old in-memory object from the original request. It should read current data.

The constructor’s public Order $order is property promotion: one line both accepts and stores the model. $tries and $backoff say “try three times, wait 30 seconds between attempts.” Temporary mail or API failures often recover; permanent bad data will not, which is why failure handling later matters.

For growing delays:

public array $backoff = [30, 60, 120];

handle() is the worker’s entry point. Everything expensive belongs here, not in the controller.

Dispatch is a design choice

SendInvoiceEmail::dispatch($order);

SendInvoiceEmail::dispatch($order)->delay(now()->addMinutes(2));

SendInvoiceEmail::dispatch($order)->onQueue('emails');

SendInvoiceEmail::dispatchSync($order);

Plain dispatch() is the default path. Delay is for reminders and “send after settle” behavior. Named queues let mail stay responsive while heavy reports wait their turn. dispatchSync() is for moments when you deliberately want the old blocking behavior—usually tests or debugging.

Named queues only help if the worker listens in the right order:

php artisan queue:work --queue=emails,default

Left to right is priority: emails first, everything else second.

Queue workers are an essential part of the feature

Dispatching a job only places it on the queue. A queue worker turns it into work.

php artisan queue:work

Locally, queue:listen is slower but friendlier while code is changing. In production, long-running queue:work processes under Supervisor (or similar) are the norm:

php artisan queue:work redis --queue=emails,default --tries=3 --timeout=90

--timeout protects you from hung PDF or API calls. And after every deploy:

php artisan queue:restart

Workers keep code in memory. If you forget to restart them, they keep running yesterday’s logic. When “queues do nothing,” the first check is still the simplest one: is a worker actually running?

A real flow: invoice email after order

Here is the same idea in a checkout-shaped example.

Controller

<?php

namespace App\Http\Controllers;

use App\Http\Requests\OrderRequest;
use App\Jobs\SendInvoiceEmail;
use App\Models\Order;

class OrderController extends Controller
{
    public function store(OrderRequest $request)
    {
        $order = Order::create([
            'customer_name' => $request->customer_name,
            'customer_email' => $request->customer_email,
            'total' => $request->total,
            'status' => 'paid',
        ]);

        SendInvoiceEmail::dispatch($order)->onQueue('emails');

        return redirect()
            ->route('orders.show', $order)
            ->with('success', 'Order placed. Invoice email is on the way.');
    }
}

The request still owns validation and persistence. The copy matters too: “on the way” tells the truth about async work. “Email sent” often does not.

Mailable

<?php

namespace App\Mail;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class InvoiceMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public Order $order)
    {
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Your invoice for order #'.$this->order->id,
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.invoice',
        );
    }
}

envelope() defines the subject. content() points at the Markdown view. Keeping send logic inside the job makes retries and logging easier to reason about than scattering queue behavior across mailables and controllers.

Email view

resources/views/emails/invoice.blade.php:

@component('mail::message')
# Thanks for your order

Hi {{ $order->customer_name }},

Your payment was received. Order ID: **#{{ $order->id }}**  
Total: **{{ number_format($order->total, 2) }}**

@component('mail::button', ['url' => url('/orders/'.$order->id)])
View order
@endcomponent

Thanks,<br>
{{ config('app.name') }}
@endcomponent

The mailable’s public $order is available in the view. Laravel’s mail components give you a usable layout without inventing email HTML from scratch.

Failures are inevitable, so prepare for them

public function handle(): void
{
    if (! $this->order->customer_email) {
        $this->fail(new \RuntimeException('Order has no customer email.'));
        return;
    }

    Mail::to($this->order->customer_email)
        ->send(new InvoiceMail($this->order));
}

public function failed(\Throwable $exception): void
{
    logger()->error('Invoice email failed', [
        'order_id' => $this->order->id,
        'error' => $exception->getMessage(),
    ]);
}

Retry temporary failures, but don’t retry invalid data. A missing email will not appear on the next attempt, so $this->fail() stops the loop. failed() is your last hook—logs, alerts, or an email_status column on the order.

php artisan queue:failed
php artisan queue:retry all
php artisan queue:flush

Those commands are how you inspect, recover, or clear the failures you decided to stop caring about.

Patterns that prevent pain later

Sometimes you need the values from dispatch time, not whatever the database says later:

public function __construct(
    public int $orderId,
    public string $email,
) {}

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

    Mail::to($this->email)->send(new InvoiceMail($order));
}

Duplicate checkout requests are more common than you might expect. Unique jobs help:

use Illuminate\Contracts\Queue\ShouldBeUnique;

class SendInvoiceEmail implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return (string) $this->order->id;
    }
}

And when work has a real sequence—PDF, then email, then accounting—chain it instead of stuffing three responsibilities into one class:

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new GenerateInvoicePdf($order),
    new SendInvoiceEmail($order),
    new NotifyAccounting($order),
])->dispatch();

Later steps run only if earlier ones succeed.

Proving the behavior

use App\Jobs\SendInvoiceEmail;
use App\Models\Order;
use Illuminate\Support\Facades\Queue;

public function test_order_dispatches_invoice_email_job(): void
{
    Queue::fake();

    $order = Order::factory()->create();

    SendInvoiceEmail::dispatch($order);

    Queue::assertPushed(SendInvoiceEmail::class, function ($job) use ($order) {
        return $job->order->is($order);
    });
}

Queue::fake() records jobs without touching Redis or the database. This asserts the right work was queued. Email content belongs in a separate test with Mail::fake() and a synchronous run of the job.

Common mistakes teams make

  • No worker running, and a growing pile of untouched jobs
  • sync left on in production
  • Huge payloads instead of IDs
  • No timeout around fragile external calls
  • Workers not restarted after deploy
  • Endless retries for data that will never become valid

In production, Redis, supervised workers, priority queues, failed-job alerts, and deploy-time restarts are not extras. They are how the feature stays trustworthy.

Summary

Laravel Queues are less about learning new syntax and more about creating clear boundaries within your application. The request creates the record and answers the user. The background job handles side effects. The worker keeps that promise over time.

If a screen feels slow, look for mail, PDFs, and API calls still living in the controller. Moving them off the request is often the difference between an eight-second wait and an immediate response.

Topics
Development Laravel