Laravel Queues Tutorial: Run Heavy Tasks in Background

A practical Laravel queues tutorial: move emails, PDF reports and exports to background jobs with Redis, Supervisor, retries and failure handling in Laravel 11.

This Laravel queues tutorial shows how to move slow work, such as emails, PDF reports and CSV exports, out of the HTTP request so your pages respond instantly. On a questionnaire platform I worked on, generating a report export took 20 seconds and users kept clicking the button again, creating duplicate exports and timeouts. Moving it to a queue made the click instant and stopped the duplicates.

In this guide you will set up queues in Laravel 11, write a robust job, choose between the database and Redis drivers, keep workers running with Supervisor, and handle retries, failures and duplicate dispatches the way I do in production.

Prerequisites

  • Laravel 11 on PHP 8.3
  • A database (MySQL or PostgreSQL) and optionally Redis 7
  • SSH access to your server to run long-lived worker processes
  • Basic familiarity with Artisan commands

When should you use Laravel queues?

A good rule: anything that takes more than about 300 milliseconds or depends on a third-party API does not belong in the request. Users should not wait for an SMTP server, and a slow API should not make your page time out.

  • Sending emails and notifications
  • Generating PDFs, invoices and reports
  • Exporting or importing large CSV and Excel files
  • Resizing images and processing uploads
  • Calling external APIs such as CRMs, payment gateways and webhooks
  • Recalculating aggregates like lead scores or statistics

Step 1: Configure the queue connection

Laravel 11 defaults to the database driver and creates the jobs table in the default migrations. If your project is older, create it manually. For Redis, install the phpredis extension or the predis package and switch the connection.

# database driver
php artisan make:queue-table
php artisan migrate

# .env
QUEUE_CONNECTION=database

# or Redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Step 2: Create a Laravel queue job for heavy tasks

Jobs implement ShouldQueue. In Laravel 11 the single Queueable trait brings in dispatching, serialization and queue interaction. Pass models, not large arrays, Laravel serializes only the model ID and reloads it fresh in the worker.

php artisan make:job GenerateReport

<?php

namespace App\Jobs;

use App\Models\Questionnaire;
use App\Models\User;
use App\Notifications\ReportReady;
use App\Services\ReportBuilder;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Throwable;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $timeout = 120;
    public array $backoff = [10, 60, 180];

    public function __construct(
        public Questionnaire $questionnaire,
        public User $user,
    ) {}

    public function handle(ReportBuilder $builder): void
    {
        $path = $builder->for($this->questionnaire)->toPdf();

        $this->user->notify(new ReportReady($path));
    }

    public function failed(?Throwable $e): void
    {
        Log::error('Report failed', [
            'questionnaire' => $this->questionnaire->id,
            'error' => $e?->getMessage(),
        ]);

        $this->user->notify(new \App\Notifications\ReportFailed($this->questionnaire));
    }
}

Step 3: Dispatch the job from a controller

The controller returns immediately, and the user gets an email or in-app notification when the report is ready. Use a separate queue name for heavy work so slow reports never delay password reset emails.

public function export(Questionnaire $questionnaire)
{
    $this->authorize('view', $questionnaire);

    GenerateReport::dispatch($questionnaire, auth()->user())
        ->onQueue('reports');

    return back()->with('status', 'Your report is being prepared. We will email you shortly.');
}

Step 4: Prevent duplicate jobs with ShouldBeUnique

This is what actually stopped the double-click problem. ShouldBeUnique uses a cache lock so a second dispatch for the same questionnaire is ignored while the first is still pending or running. It needs a cache driver that supports locks, such as Redis, database or memcached.

use Illuminate\Contracts\Queue\ShouldBeUnique;

class GenerateReport implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public int $uniqueFor = 600; // seconds

    public function uniqueId(): string
    {
        return $this->questionnaire->id . ':' . $this->user->id;
    }

    // ...
}

Step 5: Run the queue worker

Locally you can run a worker in a terminal. The order of queue names sets priority, here default jobs are processed before reports.

php artisan queue:work --queue=default,reports --tries=3 --timeout=120

# in local development, listen reloads code changes automatically
php artisan queue:listen

Step 6: Keep workers alive in production with Supervisor

A worker started from SSH dies when the session closes. Supervisor restarts it automatically and lets you run several processes. The --max-time flag recycles workers hourly to avoid memory leaks, and stopwaitsecs must be longer than your longest job.

# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=default,reports --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=180

# apply
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start "laravel-worker:*"

Step 7: Restart workers on every deploy

Workers keep your code in memory. After deploying, they still run the old version until restarted, which causes strange bugs. Add this to your deploy script.

php artisan queue:restart

Handling failed jobs and retries in Laravel

When a job exhausts its tries, Laravel stores it in the failed_jobs table. Review and retry from the command line once the root cause is fixed.

php artisan queue:failed          # list failures
php artisan queue:retry all       # retry everything
php artisan queue:retry 5f1c...   # retry one by UUID
php artisan queue:forget 5f1c...  # delete one
php artisan queue:prune-failed --hours=168

Monitoring with Laravel Horizon

If you use Redis, Horizon gives you a dashboard with throughput, runtimes and failures, and replaces the Supervisor command with php artisan horizon. On busy projects it is the first place I look when something feels slow.

composer require laravel/horizon
php artisan horizon:install

# supervisor command becomes:
command=php /var/www/app/artisan horizon

Common Laravel queue errors and fixes

  • Jobs stay in the jobs table and never run: no worker is running, or it listens to a different queue name than the one you dispatched to
  • Job runs synchronously and the page is still slow: QUEUE_CONNECTION is sync or the config is cached, run php artisan config:clear
  • Job has been attempted too many times or run too long: increase timeout and make sure retry_after in config/queue.php is larger than timeout
  • Code changes are ignored: workers were not restarted, run php artisan queue:restart
  • ModelNotFoundException in the worker: the model was deleted before the job ran, add public bool $deleteWhenMissingModels = true
  • Job dispatched inside a DB transaction runs before commit: use ->afterCommit() or set after_commit to true in the connection config

Best practices for production queues

  • Make every job idempotent so running it twice does no harm
  • Use separate queues for fast and slow work, and give critical queues priority
  • Keep job payloads small: pass IDs or models, not files or huge arrays
  • Always set tries, timeout and backoff explicitly
  • Implement failed() to notify the user or the team
  • Monitor queue length, an ever-growing backlog means you need more workers

Conclusion

Laravel queues are the simplest performance upgrade most applications can make. Move anything slow into a job, run workers under Supervisor or Horizon, restart them on deploy, and plan for retries and failures from day one. The users on that questionnaire platform never knew anything changed, except that the export button suddenly felt instant.

Frequently asked questions

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

queue:work boots the framework once and keeps it in memory, so it is fast but needs a restart after code changes. queue:listen reloads on every job, which is slower and only useful in local development.

Should I use the database or Redis queue driver?

The database driver works everywhere and is fine for moderate load. Redis is faster, handles high throughput better and is required for Horizon.

Why are my Laravel jobs not processing?

Usually no worker is running, the worker listens to a different queue name, or QUEUE_CONNECTION is still sync because of a cached config. Check with php artisan queue:work and php artisan config:clear.

How do I run Laravel queues on shared hosting?

Without long-running processes, schedule php artisan queue:work --stop-when-empty every minute through the scheduler and a cron job. It works for light workloads.

How many times does Laravel retry a failed job?

As many times as the tries property or the --tries option allows, the default is one attempt. Use backoff to wait between attempts.