Laravel Queues: Stop Making Your Users Wait

under

Every second your user waits for a confirmation email or a PDF to generate is a second they’re questioning whether your app actually works.

Why Laravel Queues Tutorial Content Always Starts Too Late

Most guides jump straight into php artisan queue:work without explaining why queues exist. Here’s the honest version: synchronous code kills user experience. When a user submits a form and your app sends an email, resizes an image, and pings a third-party API before returning a response, you’ve just made them sit through your server’s to-do list.

Queues fix this by decoupling time-consuming work from the HTTP request cycle. The user gets a response immediately. The heavy lifting happens in the background. That’s the whole idea — and once it clicks, you’ll start seeing queue opportunities everywhere in your codebase.

Laravel’s queue system is one of its genuinely great features. It abstracts over multiple backends (database, Redis, SQS, Beanstalkd) behind a consistent API. You pick the driver, write your job, dispatch it. The rest is configuration.

Setting Up Your First Queue: The Actual Minimum

Before anything else, you need a queue driver. For local development, the database driver is the path of least resistance.

php artisan queue:table
php artisan migrate

In your .env file:

QUEUE_CONNECTION=database

That’s it for local setup. For production, switch to Redis — it’s faster, more reliable, and handles failure better than a database table under load. Don’t argue with this one. Just use Redis.

QUEUE_CONNECTION=redis

Make sure you’ve pulled in the Predis package or the phpredis extension, then set your Redis config in config/database.php. Laravel’s official queue documentation covers every driver option if you need SQS or Beanstalkd instead.

Creating Your First Job Class

Generate a job with Artisan:

php artisan make:job SendWelcomeEmail

This drops a class into app/Jobs/. Open it up and you’ll see the scaffold Laravel gives you:

<?php

namespace App\Jobs;

use App\Models\User;
use App\Mail\WelcomeEmail;
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 SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public User $user) {}

    public function handle(): void
    {
        Mail::to($this->user->email)->send(new WelcomeEmail($this->user));
    }
}

The ShouldQueue interface is what tells Laravel to push this job onto the queue instead of running it inline. The SerializesModels trait handles Eloquent model serialization safely — it stores the model’s ID and rehydrates it when the job runs, so you’re not serializing an entire object graph. That detail matters more than it sounds.

Dispatching Jobs From Your Controllers

// Immediate dispatch (goes to queue)
SendWelcomeEmail::dispatch($user);

// Delayed dispatch — send in 5 minutes
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));

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

Keep your controller methods thin. Dispatch and move on. The job handles the rest.

Handling Failures Like a Professional

This is the part most Laravel queues tutorial posts skip, and it’s where things get genuinely painful in production. Jobs fail. Third-party APIs go down. Memory limits get hit. Your queue system needs to handle this gracefully — because it will happen, and probably on a Friday.

Configuring Retry Attempts

On your job class, set $tries and $timeout:

class SendWelcomeEmail implements ShouldQueue
{
    public int $tries = 3;
    public int $timeout = 30; // seconds

    // ...
}

Laravel will retry the job up to three times before marking it as failed. You can also use exponential backoff to avoid hammering a struggling service:

public function backoff(): array
{
    return [1, 5, 10]; // seconds between retries
}

The Failed Jobs Table

Run this migration to capture failed jobs:

php artisan queue:failed-table
php artisan migrate

When a job exhausts its retries, Laravel writes it to failed_jobs. You can inspect failures:

php artisan queue:failed

Retry a specific failed job:

php artisan queue:retry {id}

Retry all of them:

php artisan queue:retry all

Implement the failed() method on your job class to handle cleanup or alerting when a job dies permanently:

public function failed(\Throwable $exception): void
{
    // Notify the user, log to Sentry, clean up temp files
    logger()->error('SendWelcomeEmail failed', [
        'user_id' => $this->user->id,
        'error' => $exception->getMessage(),
    ]);
}

Running Workers in Production: What You Actually Need

Running php artisan queue:work in your terminal is fine for development. In production, that command needs to stay alive, restart after deployments, and recover from crashes. A terminal tab doesn’t cut it.

Supervisor is the standard answer on Linux servers. It’s a process monitor that keeps your workers running and restarts them when they die.

A basic Supervisor config at /etc/supervisor/conf.d/laravel-worker.conf:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/artisan queue:work redis --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/html/storage/logs/worker.log
stopwaitsecs=3600

numprocs=4 spins up four worker processes. Tune this based on your server’s CPU and your job throughput. There’s no magic number — profile it.

Critical Deployment Step

After every deployment, your workers need to be restarted. Otherwise they’re running old code. I’ve watched developers spend an hour debugging why jobs behave like the old version, and it’s always this.

php artisan queue:restart

Wire this into your deployment script. Every time. No exceptions.

Horizon for Redis Queues

If you’re on Redis, install Laravel Horizon. It gives you a real-time dashboard, job metrics, and a much cleaner way to manage worker configuration in code rather than Supervisor conf files. It’s a composer require and twenty minutes of setup — and it pays back immediately in visibility alone.

composer require laravel/horizon
php artisan horizon:install
php artisan horizon

Advanced Patterns Worth Knowing

Once you’re comfortable with the basics, these patterns solve problems you’ll actually hit.

Job Chaining

Run jobs in sequence, where each one only starts if the previous one succeeded:

Bus::chain([
    new ProcessUpload($file),
    new GenerateThumbnail($file),
    new NotifyUser($user),
])->dispatch();

Job Batching

Dispatch a group of jobs and run callbacks when they all complete (or any fail):

$batch = Bus::batch([
    new ImportRow($row1),
    new ImportRow($row2),
    new ImportRow($row3),
])->then(function (Batch $batch) {
    // All jobs succeeded
})->catch(function (Batch $batch, \Throwable $e) {
    // First failure
})->finally(function (Batch $batch) {
    // Batch complete regardless of outcome
})->dispatch();

This is especially useful when building AI-assisted workflows — batch-process documents for embedding, send all the chunks to an LLM, then aggregate results when the batch finishes. It’s a natural fit for that kind of work.

Rate Limiting Jobs

Prevent overwhelming an external API by rate-limiting how fast your jobs execute:

public function handle(): void
{
    Redis::throttle('openai-api')
        ->allow(60)
        ->every(60)
        ->then(function () {
            // Do the API call
        }, function () {
            $this->release(10); // Try again in 10 seconds
        });
}

The Practical Takeaway From This Laravel Queues Tutorial

Here’s the decision framework I actually use: if a task takes more than ~200ms or depends on an external service, it belongs in a queue. Email, file processing, webhook delivery, API calls, AI inference requests — all of it. Why would you make a user wait on any of that?

Start with the database driver locally, Redis in production, and Supervisor (or Horizon) keeping your workers alive. Set $tries and $timeout on every job — don’t skip this. Implement failed() for anything that matters. Add queue:restart to your deployment pipeline and don’t touch it again.

The shift in how your app feels to users is immediate. They click submit, they get a response, the work happens quietly in the background. That’s the version of your app worth shipping.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.