PHP & Laravel Tutorial

Laravel Queues Explained:
A Beginner's Practical Guide

Stop making your users wait. Learn how to move slow tasks — emails, image processing, reports — to the background using Laravel queues and jobs. Complete with real code, step-by-step setup, and production tips.

Laravel 12 PHP 8.3+ Database Driver Redis Driver Supervisor

Imagine you run a restaurant app. A customer signs up, and your app needs to send a welcome email, generate a PDF receipt, and update a third-party CRM — all at the same time. If you do all of that during the web request, the user stares at a loading spinner for 3–5 seconds. That is a terrible experience.

Laravel queues solve this problem elegantly. Instead of doing heavy work immediately, you push it onto a queue — a waiting list — and a background worker handles it independently. The user's response comes back in milliseconds, and the heavy work happens silently behind the scenes.

By the end of this guide you will be able to set up queues, create jobs, dispatch them, run workers, handle failures, add delays, and configure priorities — all from scratch, using Laravel's database driver (no Redis required to start).

Prerequisites

You need a working Laravel 10/11/12 project, PHP 8.1+, and Composer installed. Basic knowledge of Laravel controllers and Eloquent models is assumed.

1 What Are Queues and Jobs? (The Mental Model)

Before writing any code, let's lock in the key concepts. There are three things you need to understand:

  • Job — A PHP class that holds the task you want to run in the background (e.g., SendWelcomeEmail, GenerateInvoicePDF, ResizeProductImage).
  • Queue — The channel (a database table, Redis list, or SQS queue) where dispatched jobs wait to be picked up.
  • Worker — A long-running process (php artisan queue:work) that continuously picks jobs off the queue and executes them.

Here is how the full flow looks in a typical Laravel application:

The controller dispatches the job and immediately returns a response to the user. The worker picks up the job and runs it whenever it is ready — completely independently of the web request.

2 Choosing a Queue Driver

Laravel supports several queue backends. For beginners, start with database — it requires zero extra software. As your application scales, switch to Redis.

Driver Best For Pros Cons Badge
database Small projects, learning No extra setup, built-in Slower under high load Beginner
redis Medium–large production apps Very fast, supports Horizon Requires Redis server Production
sqs AWS infrastructure Fully managed, auto-scales Requires AWS account & config Enterprise
sync Local testing only Runs jobs immediately (no worker) Not asynchronous — defeats the purpose

This guide uses the database driver throughout. You can switch to Redis later by changing one line in your .env file.

3 Step 1 — Configure the Queue Driver

Open your .env file and set the queue connection to database:

.env
# Change this line in your .env file
QUEUE_CONNECTION=database

# For Redis instead (later), use:
# QUEUE_CONNECTION=redis

Next, create the jobs table in your database. In Laravel 12, this migration is already included by default. If it's missing, generate and run it:

Terminal
# Only needed if the jobs migration doesn't exist yet
php artisan queue:table

# Run all pending migrations
php artisan migrate

This creates a jobs table in your database where queued jobs will be stored until a worker picks them up. Open your database client — you should now see the jobs table with columns like id, queue, payload, attempts, and reserved_at.

You're done with setup!

That's it for configuration. No Redis, no SQS, no extra dependencies. The database driver works right out of the box. Now let's create your first job.

4 Step 2 — Create Your First Job

Use Artisan to generate a job class. We'll create a SendWelcomeEmail job as a practical example:

Terminal
php artisan make:job SendWelcomeEmail

This creates app/Jobs/SendWelcomeEmail.php. Open it and you'll see a class with two key methods: __construct() and handle(). Let's fill it in with real logic:

PHP — app/Jobs/SendWelcomeEmail.php
<?php

namespace App\Jobs;

use App\Models\User;
use App\Mail\WelcomeMail;
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;
use Illuminate\Support\Facades\Log;

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

    /**
     * The number of times the job may be attempted.
     */
    public int $tries = 3;

    /**
     * The number of seconds to wait before retrying a failed job.
     */
    public int $backoff = 60;

    /**
     * Create a new job instance.
     * Pass the User model in — SerializesModels handles it safely.
     */
    public function __construct(
        public readonly User $user
    ) {}

    /**
     * Execute the job.
     * This runs in the background — take as long as you need.
     */
    public function handle(): void
    {
        Log::info('Sending welcome email to: ' . $this->user->email);

        Mail::to($this->user)
            ->send(new WelcomeMail($this->user));

        Log::info('Welcome email sent successfully to: ' . $this->user->email);
    }

    /**
     * Handle a job failure — called when all retries are exhausted.
     */
    public function failed(\Throwable $exception): void
    {
        Log::error('SendWelcomeEmail failed for user ' . $this->user->id
            . ': ' . $exception->getMessage());
    }
}

Notice the job implements ShouldQueue — this is what tells Laravel to push it onto the queue instead of running it immediately. Without this interface, the job would execute synchronously.

What does SerializesModels do?

When you pass an Eloquent model (like $user) to a job, Laravel doesn't store the entire object in the queue. It stores just the model's ID and re-fetches it fresh from the database when the worker runs the job. This prevents stale data and reduces queue payload size.

5 Step 3 — Dispatch the Job

Dispatching means pushing the job onto the queue. You can dispatch from a controller, a route, a model observer, a command — anywhere in your application.

Dispatching from a Controller

PHP — app/Http/Controllers/AuthController.php
<?php

namespace App\Http\Controllers;

use App\Jobs\SendWelcomeEmail;
use App\Models\User;
use Illuminate\Http\Request;

class AuthController extends Controller
{
    public function register(Request $request)
    {
        // Create the user
        $user = User::create([
            'name'     => $request->name,
            'email'    => $request->email,
            'password' => bcrypt($request->password),
        ]);

        // ✅ Dispatch the job — returns INSTANTLY to the user
        SendWelcomeEmail::dispatch($user);

        return response()->json([
            'message' => 'Registration successful! Check your email.'
        ], 201);
    }
}

The user gets a 201 Created response immediately. The email is sent asynchronously by the queue worker — no waiting.

Other dispatch methods

PHP — Dispatch variations
// Dispatch with a delay (process after 10 minutes)
SendWelcomeEmail::dispatch($user)
    ->delay(now()->addMinutes(10));

// Dispatch to a specific named queue
SendWelcomeEmail::dispatch($user)
    ->onQueue('emails');

// Dispatch conditionally
SendWelcomeEmail::dispatchIf($user->email_verified, $user);

// Dispatch using the helper function
dispatch(new SendWelcomeEmail($user));

// Run synchronously RIGHT NOW (skips the queue — useful for testing)
SendWelcomeEmail::dispatchSync($user);

6 Step 4 — Run the Queue Worker

Dispatching puts the job in the queue. Now you need a worker to actually process it. Open a new terminal window and run:

Terminal
# Start the default queue worker
php artisan queue:work

# Process only the 'emails' queue
php artisan queue:work --queue=emails

# Process with priority: 'high' first, then 'default'
php artisan queue:work --queue=high,default

# Process a single job then stop (useful for cron jobs)
php artisan queue:work --once

# Listen mode — restarts after every job (slower but picks up code changes)
php artisan queue:listen

You'll see output like this in your terminal as jobs are processed:

Worker output
  INFO  Processing jobs from the [default] queue.

  2026-04-04 10:23:01 App\Jobs\SendWelcomeEmail .......... RUNNING
  2026-04-04 10:23:02 App\Jobs\SendWelcomeEmail ....... 2.45ms DONE
Important: queue:work vs queue:listen

Use queue:work in production (faster, caches classes in memory). Use queue:listen in local development when you are actively changing job code, since it restarts the worker after each job and picks up code changes automatically.

7 Step 5 — Handle Failed Jobs

What happens when a job throws an exception? By default, Laravel retries it up to your configured $tries limit. After all retries are exhausted, it moves the job to the failed_jobs table.

Create the failed jobs table

Terminal
# Create the failed_jobs migration (Laravel 12 may include this already)
php artisan queue:failed-table
php artisan migrate

Working with failed jobs

Terminal — Failed job commands
# View all failed jobs
php artisan queue:failed

# Retry a specific failed job by its ID
php artisan queue:retry 5

# Retry ALL failed jobs at once
php artisan queue:retry all

# Delete a specific failed job
php artisan queue:forget 5

# Clear all failed jobs from the table
php artisan queue:flush

Configuring retry behaviour in your Job class

PHP — Job retry configuration
class SendWelcomeEmail implements ShouldQueue
{
    // Maximum number of attempts before marking as failed
    public int $tries = 3;

    // Wait 60 seconds between retries (exponential: 60, 120, 180...)
    public int $backoff = 60;

    // Maximum seconds a single attempt may run before timing out
    public int $timeout = 120;

    // Only retry until this specific time
    public function retryUntil(): \DateTime
    {
        return now()->addHours(2);
    }
}

8 Step 6 — Queue Priorities and Multiple Queues

As your application grows, you'll want some jobs to be processed before others. For example, password reset emails should be sent before a weekly newsletter. Use named queues to prioritise jobs.

PHP — Dispatching to named queues
// High priority — transactional emails
SendPasswordResetEmail::dispatch($user)
    ->onQueue('high');

// Normal priority — welcome emails
SendWelcomeEmail::dispatch($user)
    ->onQueue('default');

// Low priority — newsletters, reports
SendWeeklyNewsletter::dispatch()
    ->onQueue('low');

Start the worker with priority order — it processes high first, then default, then low:

Terminal
php artisan queue:work --queue=high,default,low

9 Step 7 — Run Workers in Production with Supervisor

In production, you must not run php artisan queue:work manually. If your terminal closes, the worker stops and jobs pile up unprocessed. The solution is Supervisor — a Linux process manager that keeps your worker alive automatically, even after server reboots.

1

Install Supervisor

Run on your Ubuntu/Debian server: sudo apt-get install supervisor

2

Create a Supervisor config file

Create /etc/supervisor/conf.d/laravel-worker.conf with the configuration below.

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

Start Supervisor

Run these three commands to activate the configuration:

Terminal — Supervisor commands
# Reload Supervisor configuration
sudo supervisorctl reread
sudo supervisorctl update

# Start the laravel-worker group
sudo supervisorctl start laravel-worker:*

# Check status
sudo supervisorctl status

You should see 4 worker processes (matching numprocs=4) all showing RUNNING. If a worker crashes, Supervisor restarts it automatically within seconds.

After deploying new code — restart workers

Because queue:work caches your code in memory, workers won't pick up code changes until restarted. Add this to your deployment script: php artisan queue:restart. Supervisor will gracefully restart all workers.

10 Real-World Example: Image Resize Queue

Let's look at another practical example — resizing a product image after upload. This is a perfect queue use case because image processing is CPU-intensive and should never block the web request.

Terminal
php artisan make:job ResizeProductImage
PHP — app/Jobs/ResizeProductImage.php
<?php

namespace App\Jobs;

use App\Models\Product;
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\Storage;
use Intervention\Image\Facades\Image;

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

    public int $tries = 2;
    public int $timeout = 300; // 5 minutes max

    public function __construct(
        public readonly Product $product,
        public readonly string  $imagePath
    ) {}

    public function handle(): void
    {
        $sizes = [
            'thumb'  => [150,  150],
            'medium' => [600,  600],
            'large'  => [1200, 1200],
        ];

        foreach ($sizes as $name => [$w, $h]) {
            $resized = Image::make(
                Storage::path($this->imagePath)
            )->fit($w, $h)->encode('webp', 85);

            $path = "products/{$this->product->id}/{$name}.webp";
            Storage::put($path, $resized);
        }

        $this->product->update(['images_processed' => true]);
    }
}

Dispatch it from your product upload controller:

PHP — Controller dispatch
public function store(Request $request)
{
    $path    = $request->file('image')->store('uploads');
    $product = Product::create($request->validated());

    // Dispatch image resizing to the background ✅
    ResizeProductImage::dispatch($product, $path)
        ->onQueue('images');

    // User gets this response INSTANTLY — no waiting for image processing
    return response()->json([
        'message' => 'Product created! Images are being processed.',
        'product' => $product,
    ], 201);
}

11 Laravel Queues Beginner Checklist

Use this checklist to confirm you have set up queues correctly:

  • QUEUE_CONNECTION=database set in .env
  • php artisan migrate run — jobs table exists in database
  • Job class implements ShouldQueue interface
  • Job uses Dispatchable, InteractsWithQueue, Queueable, SerializesModels traits
  • $tries and $timeout configured on the job
  • failed() method defined to handle exhausted retries
  • failed_jobs table created and migrated
  • Queue worker running (queue:work or via Supervisor in production)
  • php artisan queue:restart added to deployment script
  • Supervisor configured in production with autorestart=true

12 Frequently Asked Questions

What is the difference between a job and a queue in Laravel?
A job is the PHP class containing the task you want to perform in the background (e.g., sending an email). A queue is the storage channel — a database table or Redis list — where dispatched jobs wait until a worker picks them up. Jobs go into queues; workers process them from queues.
What is the best queue driver for beginners?
The database driver is the best starting point. It requires no additional software — just set QUEUE_CONNECTION=database and run php artisan migrate. Once your application grows and processes hundreds of jobs per minute, switch to Redis for significantly better performance and access to Laravel Horizon.
How do I handle failed jobs in Laravel?
Run php artisan queue:failed-table && php artisan migrate to create the failed_jobs table. Laravel logs failed jobs there automatically after all retries are exhausted. Use php artisan queue:retry all to re-queue everything, or php artisan queue:retry {id} for a specific job. Define a failed() method in your job class to add custom error handling like notifications or logging.
Can I delay a job in Laravel?
Yes, using the delay() method: SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10)); — this holds the job in the queue for 10 minutes before any worker picks it up. Useful for things like sending a follow-up email 24 hours after registration.
Why does my job run immediately even though I set up a queue?
Check your .env file — if QUEUE_CONNECTION=sync, jobs run synchronously and ignore the queue entirely. Change it to database (or redis). Also make sure you're not using dispatchSync() accidentally, which forces synchronous execution regardless of driver.

Conclusion

Laravel queues are one of the most impactful features you can add to a web application. They transform slow, blocking operations into instant responses — making your app feel fast and professional from the very first interaction.

Here's what you've learned in this guide:

  1. The mental model: jobs, queues, and workers explained simply
  2. How to configure the database queue driver with zero extra dependencies
  3. Creating a fully-featured job class with retries, backoff, and failure handling
  4. Dispatching jobs with delays, priorities, and named queues
  5. Running and managing failed jobs from the CLI
  6. Deploying queue workers reliably in production using Supervisor

The next step is to identify the slowest operations in your own Laravel application — email sending, PDF generation, API calls, image processing — and move them into jobs. Your users will notice the difference immediately.

Need help building a scalable Laravel application with queues, jobs, and background processing? Contact Jahid Babu Tech for a free consultation.