A customer clicks "Place order" and stares at a spinner for four seconds. In those four seconds the server generates a PDF invoice, sends an email, calls an SMS gateway and perhaps notifies the accounting system. None of that has to finish before the customer gets a response.

That is exactly what Laravel queues are for: the request does the essential work, pushes the slow tasks onto a queue and responds immediately. A separate process, the worker, picks those tasks up and runs them in the background.

This guide goes from your first job to running workers reliably in production with Laravel 13.

Short answer: Laravel queues move slow work such as sending email, generating PDFs and calling external APIs out of the user's request: you dispatch it as a job, and a separate worker (php artisan queue:work) runs it in the background. In production, keep the worker running with systemd or Supervisor, define tries, backoff and timeout on the job, and run php artisan queue:restart after every deploy.

What to send to the queue

Any task with these three traits is a good candidate:

  • It's slow or its duration is unpredictable: sending email and SMS, generating PDFs, processing images, calling external APIs.
  • The user isn't waiting for the result; it's enough to know the task was accepted.
  • It can fail and should be retried: an SMS gateway occasionally doesn't respond, and that shouldn't make placing an order fail.

By contrast, form validation, saving the order itself and anything the page response depends on should stay in the request.

Creating a job

This command generates a job class:

bash
php artisan make:job SendInvoice

And here is the finished version:

app/Jobs/SendInvoice.php
<?php

namespace App\Jobs;

use App\Mail\InvoiceMail;
use App\Models\Order;
use App\Services\InvoicePdf;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;

class SendInvoice implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    /** Maximum number of attempts */
    public int $tries = 5;

    /** Maximum run time per attempt, in seconds */
    public int $timeout = 120;

    /** The uniqueness lock is held for at most one hour */
    public int $uniqueFor = 3600;

    public function __construct(public Order $order)
    {
    }

    /** Delay between attempts: 10 seconds, 1 minute, 5 minutes, 15 minutes */
    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    public function uniqueId(): string
    {
        return (string) $this->order->id;
    }

    public function handle(InvoicePdf $pdf): void
    {
        // Already sent? Do nothing (idempotency)
        if ($this->order->invoice_sent_at !== null) {
            return;
        }

        $path = $pdf->generate($this->order);

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

        $this->order->forceFill(['invoice_sent_at' => now()])->save();
    }

    public function failed(?Throwable $exception): void
    {
        Log::error('Sending invoice failed', [
            'order_id' => $this->order->id,
            'error' => $exception?->getMessage(),
        ]);
    }
}

A few notes on this class:

  • In Laravel 13, the Queueable trait includes SerializesModels. Only the order's ID is stored in the queue, not the whole model, and the worker reloads the model fresh from the database when it runs. So invoice_sent_at always reflects the current value.
  • Resolve dependencies (such as InvoicePdf) in handle, not in the constructor. The constructor should only take data that needs to be stored in the queue.
  • InvoiceMail should not implement ShouldQueue itself; we're already inside a queued job, and send runs right here.

Dispatching to the queue

In a controller or service:

app/Http/Controllers/OrderController.php
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreOrderRequest;
use App\Jobs\SendInvoice;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderController extends Controller
{
    public function store(StoreOrderRequest $request)
    {
        $order = DB::transaction(function () use ($request) {
            $order = Order::create($request->validated());

            SendInvoice::dispatch($order)->afterCommit();

            return $order;
        });

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

afterCommit() matters. Without it, a worker can pick up the job before the transaction commits, fail to find the order in the database, and the job fails with a ModelNotFoundException. To make this the default for every job, set 'after_commit' => true for the connection in config/queue.php.

Separate queues for work of different priorities are also useful:

php
SendInvoice::dispatch($order)->onQueue('emails');
GenerateMonthlyReport::dispatch()->onQueue('reports')->delay(now()->addMinutes(5));

Retries, backoff and timeout

Any job can fail: the network drops, an external service returns a 500. Three settings control what happens then:

Setting Meaning
$tries How many attempts before the job counts as failed
backoff() or $backoff Seconds to wait between attempts; an array sets each delay separately
$timeout If an attempt runs longer than this, the worker kills it

Values defined on the job take precedence over the --tries and --timeout options of queue:work.

Don't overlook one important relationship: each connection in config/queue.php has a retry_after value (90 seconds by default). If a job is still running after that long, the queue assumes the worker has died and hands the job to another worker. So timeout must always be a few seconds shorter than retry_after; otherwise a long job can run twice at the same time. In the example above, timeout is 120, so that connection's retry_after should be raised to something like 180.

ShouldBeUnique: once in the queue, not ten times

If a user double-clicks the button or an event fires twice, two identical jobs can end up in the queue. By implementing ShouldBeUnique and a uniqueId() method, while a SendInvoice for a given order is queued or running, further dispatches for the same order are ignored.

The uniqueness lock is released when the job completes successfully or exhausts all its attempts. $uniqueFor caps how long the lock is held, for the case where a worker dies mid-job. The lock lives in the cache, so the cache driver must support locks and be shared across all servers; Redis or database are fine, file on multiple servers is not.

Failed jobs and the failed method

When all attempts are used up, the job is recorded in the failed_jobs table and its failed() method runs. That's the place to log, alert the support team or flag the order as needing attention.

Commands you'll use often:

bash
php artisan queue:failed                 # list failed jobs
php artisan queue:retry 5f1c...           # retry one job by UUID
php artisan queue:retry all              # retry all of them
php artisan queue:forget 5f1c...          # remove one job from the list
php artisan queue:prune-failed --hours=168   # delete records older than a week

Add queue:prune-failed to the scheduler so the table doesn't grow forever.

Idempotency: assume every job runs twice

Queues don't guarantee exactly-once delivery. A job can be interrupted after sending the email but before saving invoice_sent_at, and the next attempt sends the email again. Or retry_after is misconfigured and two workers pick up the same job.

The right approach is to write jobs so that running them again does no harm:

  • Check the state before doing the work, like invoice_sent_at in the example above.
  • For financial operations, use a unique key in the database (for example a unique index on payment_reference) so duplicate inserts are rejected.
  • If the external service accepts an idempotency key, send the order ID as that key.
  • Decide which is worse: a duplicate email or a missing one. If you save invoice_sent_at before sending, you get no duplicates but may lose an email; save it after, and the reverse is true. For invoices, a duplicate is usually the lesser evil.

Database or Redis

Laravel 13 uses the database queue driver by default, and new projects already include the migration for the jobs table.

database redis
Setup No extra service Requires installing and maintaining Redis
Performance Plenty for a few dozen jobs per minute For high volume and low latency
Load on the database Workers poll the table constantly None
Horizon Not supported Supported

For most small and medium sites, database is a good starting point. When job volume grows, you add more workers, or you want the Horizon dashboard, move to Redis; the job code doesn't change, only QUEUE_CONNECTION in .env.

Running workers in production

In development, php artisan queue:work in a terminal is enough. In production you need a process that's always up, restarts after a crash and starts automatically after a reboot. The two common tools for this are Supervisor and systemd. On Ubuntu 24.04, systemd is already there, so there's nothing extra to install.

A systemd unit

Create a template unit so you can run several workers at once:

/etc/systemd/system/laravel-worker@.service
[Unit]
Description=Laravel queue worker %i
After=network-online.target
Wants=network-online.target

[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com
ExecStart=/usr/bin/php artisan queue:work database --queue=emails,default --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5
# Enough time for the current job to finish on stop
TimeoutStopSec=150

[Install]
WantedBy=multi-user.target

Then enable two workers:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now laravel-worker@1 laravel-worker@2
sudo systemctl status 'laravel-worker@*'
journalctl -u laravel-worker@1 -f

A few notes:

  • --queue=emails,default means the worker drains the emails queue first and then moves on to default.
  • --max-time=3600 makes the worker exit gracefully after an hour, and Restart=always brings it back up. This prevents memory usage from creeping up over time. --max-jobs does the same based on the number of jobs processed.
  • On stop, systemd sends SIGTERM, and Laravel's worker finishes the current job before exiting. Set TimeoutStopSec higher than the timeout of your longest job.

If you prefer Supervisor

/etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/example.com/artisan queue:work database --sleep=3 --tries=3 --max-time=3600
user=www-data
numprocs=2
autostart=true
autorestart=true
stopwaitsecs=150
stdout_logfile=/var/www/example.com/storage/logs/worker.log

Then run sudo supervisorctl reread && sudo supervisorctl update.

Restart workers after every deploy

A worker is a long-running process that loads your application code into memory once. If you don't restart it after a deploy, it keeps running the old code, one of the most common reasons behind "I changed it but nothing happened".

In your deploy script, after updating the code and running migrations:

bash
php artisan migrate --force
php artisan optimize
php artisan queue:restart

queue:restart doesn't kill anything; it just sets a flag in the cache. Each worker sees the flag after finishing its current job and exits, and systemd or Supervisor starts it again with the new code. Two prerequisites: the cache driver must not be array, and all workers must share the same cache.

Frequently asked questions

What is a job in Laravel?

A job is a class that implements ShouldQueue and performs one specific task in its handle method. You put it on the queue with dispatch, and a worker runs it in the background without the user waiting for it to finish.

Why are my Laravel queue jobs not running?

The most common reason is that no worker is running at all; the job simply sits in the jobs table or in Redis until a queue:work process picks it up. Other causes are a worker listening to a different queue name (the --queue option), or a worker that wasn't restarted after a deploy and is still running old code.

What is the difference between queue:work and queue:listen?

queue:work boots the application once and stays in memory, so it's fast but doesn't see code changes until it's restarted. queue:listen reboots the application for every job and is slower; it's only useful in development, and production should always use queue:work.

Where are failed Laravel jobs stored?

Once a job has used up all its attempts, it's recorded in the failed_jobs table. List them with php artisan queue:failed, retry them with queue:retry, and delete old records with queue:prune-failed.

Should I use Redis or the database driver for Laravel queues?

For small and medium sites, the default database driver is enough and needs no extra service. Move to Redis when job volume grows or you want Horizon; the job code stays the same and only QUEUE_CONNECTION changes.

Wrap-up

  • Queue any task that is slow, not needed for the response, and liable to fail.
  • Define tries, backoff and timeout explicitly on the job, and keep timeout below retry_after.
  • Dispatch inside transactions with afterCommit().
  • Use ShouldBeUnique to prevent duplicate jobs, but always write jobs to be idempotent.
  • Implement failed() and prune the failed_jobs table regularly.
  • Start with database and move to Redis when you need to.
  • Run workers under systemd or Supervisor, and run queue:restart on every deploy.

For more detail, see the official Laravel queues documentation: https://laravel.com/docs/queues