f in x
Laravel Queues: Background Job Processing and Failure Handling
> cd .. / HUB_EDITORIALE
Analisi dei dati e metriche

Laravel Queues: Background Job Processing and Failure Handling

[2026-06-11] Author: Ing. Calogero Bono

Your e-commerce takes 8 seconds to show the order summary? The admin panel freezes while sending 200 notification emails? That's a classic symptom we've seen over and over: you're executing heavy operations inside the request-response cycle. A website is not a showcase to admire: it's a tool that must sell. If the customer waits, you lose. At Meteora Web, we have built platforms that handle thousands of jobs per day without keeping anyone waiting. This guide explains how Laravel Queues work, how to set them up, and most importantly, how to manage failures without losing data.

Why Queue a Task

Think of a restaurant kitchen. The waiter takes the order (the HTTP request) and passes it to the kitchen (the job). The customer doesn't wait for the plate to be ready: they go back to the table, enjoy their wine, while the kitchen works in parallel. The same applies to a web application. A queued job allows you to:
Improve user experience: the page responds instantly, even if a long process is running behind the scenes (email sending, PDF generation, image processing).
Scale horizontally: you can add more workers to process more jobs concurrently, without touching the web server.
Handle traffic spikes: the queue accumulates and workers drain the backlog without crashing the site.

We measure it in response time: an email job can drop from 3 seconds to 3 milliseconds for the user. The rest happens later, in the queue. The client never notices.

Connection Options: Which One to Choose

Laravel supports several queue drivers. The choice depends on volume, budget, and infrastructure.

Database

The database driver uses a MySQL/PostgreSQL table. It's the simplest: no external service, just a PHP worker. Great for small to medium projects (up to a few thousand jobs per day). We use it in the early months of a project when volume is low and we want zero additional cost.
Downside: it writes to the database on every cycle, can become a bottleneck. For high loads, switch to Redis.

Redis

High-performance driver: queues are in-memory lists, extremely fast. With Horizon (the official monitoring panel for Redis) you get metrics, automatic restarts, load balancing. We recommend it for production projects with medium/high volumes. Requires Redis installed and configured.

Amazon SQS

Serverless solution: no daemon to maintain, pay as you go. Ideal if you're already on AWS. The downside is higher latency (polling every X seconds) and dependence on an external service.

Rule of thumb: start with database, move to Redis when the load grows. Never neglect worker configuration in production with Supervisor to keep the worker always alive.

Creating and Dispatching a Job

A job in Laravel is a class that implements the ShouldQueue interface.

php artisan make:job ProcessOrder

Basic structure:

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

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

    public $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
    }

    public function handle()
    {
        // Heavy logic: calculations, email, notifications
        $this->order->markAsProcessed();
        // Send confirmation email
        Mail::to($this->order->user)->send(new OrderConfirmation($this->order));
    }
}

To dispatch it to the queue:

ProcessOrder::dispatch($order);

If you want to run it synchronously (useful for tests):

ProcessOrder::dispatchSync($order);

Or with a delay:

ProcessOrder::dispatch($order)->delay(now()->addMinutes(10));

Serializing Data

When a job is queued, Laravel serializes its public properties and uses SerializesModels for Eloquent models. This means the model is fetched from the database at execution time, not at dispatch time. This prevents stale data.

The Worker: Who Executes the Jobs

The worker is a PHP process that listens to the queue and runs jobs. Start it with:

php artisan queue:work

Key options:
- --queue=high,default,low: queue priority
- --tries=3: maximum attempts
- --delay=5: seconds to wait before retrying a failed job
- --sleep=3: seconds to pause when no jobs are available (saves CPU)

In production, don't run queue:work manually. Use Supervisor to keep it alive. Example configuration:

[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
user=forge
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/worker.log

With numprocs=4 you have 4 parallel workers on the same queue. Increase them when the load rises.

Handling Failures: Retries, Backoff, and Dead Letter Queue

Jobs fail. It could be a transient error (database down, external API timeout) or permanent (corrupt data, wrong logic). Laravel provides tools for both cases.

Retries and Backoff

Set $tries in the job class for the maximum number of attempts:

public $tries = 5;

Backoff (interval between retries) can be fixed or dynamic using the InteractsWithQueue trait:

public function backoff()
{
    return [10, 30, 60, 120, 300]; // seconds
}

First retry after 10 seconds, then 30, then 60, etc. Useful for APIs with rate limits.

Max Exceptions

If a job throws exceptions but you want to retry only after a certain number of specific failures, use $maxExceptions. For example, if an external service is down, retrying indefinitely is pointless.

RetryUntil

You can set an absolute timeout for a job, after which it is permanently marked as failed:

public function retryUntil()
{
    return now()->addMinutes(30);
}

Useful for time-sensitive jobs (e.g., booking confirmation).

The failed() Method

Override the failed method to perform actions when a job exhausts its attempts (e.g., notify admin, log, write to an error table).

public function failed(\Throwable $exception)
{
    Log::error('Order processing failed: ' . $this->order->id);
    // Send Slack or email notification
    Notification::send(User::admin(), new JobFailedNotification($this->order));
}

Failed Jobs Table

By default, failed jobs are saved in the failed_jobs table. View them with:

php artisan queue:failed

Retry all failed:

php artisan queue:retry all

Or delete them:

php artisan queue:flush

This table is your Laravel dead letter queue. Monitor it regularly. We've seen businesses lose orders because they didn't check failed jobs. A cron job that sends a daily report of failed jobs is a good practice.

Job Middleware: Logging, Rate Limiting, Locking

Laravel allows applying middleware to jobs, just like routes. Some useful built-in ones:

  • ThrottlesExceptions: prevents a job from repeatedly failing due to temporary exceptions (e.g., 429 Too Many Requests).
  • RateLimited: limits the number of jobs executed per unit of time, practical for APIs with limits.
  • WithoutOverlapping: prevents the same job (based on a key) from being executed concurrently. Useful for processes that must not overlap, such as synchronization with an external system.

Example with ThrottlesExceptions:

use Illuminate\Queue\Middleware\ThrottlesExceptions;

public function middleware()
{
    return [
        (new ThrottlesExceptions(5, 10))->backoff(2),
    ];
}

Allows 5 attempts in 10 minutes, with a 2-second backoff between attempts. This avoids overloading an unstable service.

Production Monitoring with Horizon

If you use Redis, Laravel Horizon is a must. It provides a dashboard with real-time metrics: running, completed, and failed jobs. You can restart workers, see average execution time, and set automatic balancing between queues with priority. We install it on every project that exceeds a few hundred jobs per day. Configuration in config/horizon.php allows defining "supervisors" and "queues" with different weights.

Common Mistakes and How to Avoid Them

  • Not configuring a worker in production: if you only run queue:work in a terminal, the worker dies when you close the session. Always use Supervisor.
  • Ignoring failed jobs: a failed job won't be reprocessed unless you retry it. Set an alert (email, Slack) for failures.
  • Serializing non-serializable objects: avoid passing resources (file handles, connections) as job properties. Pass IDs and fetch models inside handle().
  • Not testing jobs in isolation: use Queue::fake() in tests to ensure the job is dispatched, but also test actual execution with an integration test.
  • Mixing up dispatch and dispatchNow: dispatchNow runs synchronously, bypassing the queue. Use it only for tests or when the queue is unavailable.

In Summary — What to Do Now

  1. Identify slow operations in your application (email sending, file processing, external API calls).
  2. Create a job for each and move the logic into the handle() method.
  3. Choose the queue driver: database to start, Redis for production.
  4. Configure Supervisor to keep the worker alive.
  5. Set $tries and backoff on every job.
  6. Monitor the failed_jobs table and set up notifications for failures.
  7. Evaluate Horizon if using Redis for more granular control.

A website is not a place to wait – it's a tool that must perform. With properly configured queues, response times drop, users are happy, and your application scales. We've seen clients go from 12 seconds load time to under 1 second by moving three jobs to the queue. The numbers speak for themselves.

Sponsored Protocol

Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Co-founder di Meteora Web. Ingegnere informatico, sviluppo ecosistemi digitali ad alte prestazioni. AI, automazione, SEO tecnica e infrastrutture web. Scrivo di tecnologia per rendere complesso… semplice.

[ Read Full Dossier ]

Hai bisogno di applicare questa strategia?

Esegui il protocollo di contatto per iniziare un progetto con noi.

> INIZIA_PROGETTO

Sponsored

> MW_JOURNAL

> READ_ALL()