Mahbubur Riad
Back to blog
Laravel 6 min read

Laravel Queues: A Practical Guide to Background Jobs That Don't Break the Bank

Jun 16, 2026 · Mahbubur Riad

Stop blocking requests with heavy tasks. Learn how Laravel queues work, when to use them, and how to avoid common pitfalls—without over-engineering your app.

Laravel Queues: A Practical Guide to Background Jobs That Don't Break the Bank
On this page

Laravel Queues: A Practical Guide to Background Jobs That Don't Break the Bank

I’ve seen it too many times: a Laravel app works fine in development, then crashes under load because a single user clicked "Generate Report" and the whole server froze for 30 seconds while sending 5,000 emails.

That’s not scalability—that’s fragility.

Laravel queues exist specifically to prevent this. But here’s the thing: most tutorials stop at php artisan queue:work. They don’t tell you what happens when your queue worker crashes silently, or why your jobs keep re-running, or how to avoid memory leaks in long-running processes.

Let’s fix that. This guide assumes you’ve already used basic queue features—but want to own them. We’ll cover configuration, failure handling, performance tuning, and real-world trade-offs. No fluff. Just what works in production.

Why Queues? (The Hard Truth)

You don’t need queues for everything. But you do need them when:

  • A task takes > 1 second (per Laravel docs, anything > 2 seconds shouldn’t block a request)
  • The task is non-critical to the user flow (e.g., sending confirmation emails, resizing images)
  • The task has external dependencies (APIs, payment gateways, SMS services)

But here’s what people miss: queues add complexity. If your app has 100 concurrent users and you queue everything, your database might choke on job metadata. If you queue too little, your app feels sluggish.

Rule of thumb: If the user doesn’t see the result within 2 seconds, it probably belongs in a queue.

Laravel Queue Drivers: Which One Should You Use?

Laravel supports 6 drivers out of the box. Here’s how they compare in real production use:

Driver Best For Cons Memory Overhead Horizontal Scaling
database Small-to-mid apps, MVPs Slower than Redis, needs job table Low Moderate (scale workers)
redis High-traffic apps, real-time features Requires Redis server, config overhead Medium Excellent
database:batch Large batch jobs (e.g., CSV imports) Only works with batch() method Low Good
sqs AWS-first stacks AWS lock-in, latency Low Good
beanstalkd Legacy systems Declining adoption, no native Laravel support Low Poor
sync Testing only Blocks requests None None

I’ve seen teams start with database for an MVP, then migrate to redis after hitting 10k jobs/day. Why? Because database jobs hit jobs table on every push and pop, and MySQL/Postgres locks get contentious.

Recommendation: Start with database if you’re under 5k jobs/day. Move to redis when:

  • You see jobs table locking in SHOW PROCESSLIST
  • Your queue latency spikes during peak hours
  • You need pub/sub features (e.g., real-time job status)

Setting Up Queues: The Minimal Production-Grade Config

Here’s how I configure queues in every production app—no matter how small.

1. .env Configuration

ENV
QUEUE_CONNECTION=database
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Even if you use database, keep REDIS_HOST defined. Why? Because later, if you switch to redis, you’re already set.

2. Database Migration (If Using database Driver)

Run:

Bash
php artisan queue:table
php artisan migrate

Then, add these columns to jobs table for better observability:

PHP
// database/migrations/xxxx_xx_xx_add_queue_columns_to_jobs_table.php

Schema::table('jobs', function (Blueprint $table) {
    $table->unsignedInteger('attempts')->default(0);
    $table->unsignedInteger('reserved_at')->nullable();
    $table->unsignedInteger('available_at');
    $table->unsignedInteger('created_at');
});

This lets you debug jobs with raw SQL:

SQL
SELECT * FROM jobs WHERE reserved_at IS NULL AND created_at < NOW() - INTERVAL 5 MINUTE;

3. The queue:work Command That Actually Works

Never run php artisan queue:work bare in production. Use this:

Bash
php artisan queue:work --timeout=60 --tries=3 --memory=128 --sleep=3

Let’s break down why each flag matters:

Flag Why It Matters Real-World Consequence of Omission
--timeout=60 Kills jobs stuck > 60s (e.g., API timeouts) Zombie jobs that tie up workers
--tries=3 Prevents infinite retry loops on transient errors 10,000 failed emails in 5 mins
--memory=128 Restarts worker after 128MB usage Memory leaks → worker crashes
--sleep=3 Waits 3s if queue empty CPU spinning at 100% idle

Handling Failures: The Only Way That Won’t Lose Jobs

Your first instinct might be to log failures and move on. But that’s how jobs disappear.

Here’s my failure-handling pattern:

Step 1: Log everything (even successes)

In app/Providers/EventServiceProvider.php:

PHP
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Support\Facades\Log;

public function boot(): void
{
    Queue::after(function (JobProcessed $event) {
        Log::info('job.completed', [
            'job' => $event->job->resolveName(),
            'time' => $event->job->resolvedTime(),
        ]);
    });

    Queue::before(function (JobProcessing $event) {
        Log::info('job.started', [
            'job' => $event->job->resolveName(),
            'payload' => $event->job->payload(),
        ]);
    });
}

Step 2: Use failed() in Jobs

PHP
// app/Jobs/SendReport.php

public function failed(\Throwable $exception)
{
    // Log to Sentry, Slack, or DB
    report($exception);

    // Update user-facing status
    $this->report->update(['status' => 'failed', 'error' => $exception->getMessage()]);
}

Step 3: Process Failed Jobs Safely

Never run php artisan queue:retry all. That retries everything—including jobs that failed due to invalid user input or permanent system errors.

Instead:

Bash
# Retry only failed jobs from last 24h
php artisan queue:retry $(php artisan queue:failed | awk '/^ *[0-9]+ +/{print $1}' | tail -n 20)

Or better: use a scheduled job to retry only jobs that failed due to network issues (e.g., HTTP 5xx, not 4xx):

PHP
// app/Console/Commands/RetryTransientFailures.php

public function handle()
{
    $failed = DB::table('failed_jobs')
        ->where('failed_at', '>=', now()->subDay())
        ->where('exception', 'like', '%500%')
        ->where('exception', 'not like', '%404%')
        ->get();

    foreach ($failed as $job) {
        Queue::pushRaw($job->payload, 'default');
        DB::table('failed_jobs')->where('id', $job->id)->delete();
    }
}

Performance Tuning: What Actually Moves the Needle

Most guides stop at php artisan queue:work. But if you’re processing > 100 jobs/sec, these tweaks matter:

1. Use queue:work with --max-jobs

Bash
php artisan queue:work --max-jobs=500

Why? Because PHP memory leaks happen slowly. Restarting workers every 500 jobs prevents silent slowdowns.

2. Use Redis with Predis for Lower Latency

If you’re on redis, install predis/predis and configure:

PHP
// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'predis'),
    'options' => [
        'cluster' => env('REDIS_CLUSTER', 'redis'),
        'prefix' => env('REDIS_PREFIX', ''),
    ],
    'default' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => 0,
    ],
],

Predis is faster than ext-redis for small jobs (< 10KB payload). Test with redis-cli --latency.

3. Batch Jobs for Throughput

PHP
// app/Jobs/ProcessBatch.php

public function handle()
{
    $batch = DB::table('jobs_queue')
        ->where('status', 'pending')
        ->limit(100)
        ->get();

    foreach ($batch as $item) {
        // Process item
    }

    DB::table('jobs_queue')
        ->whereIn('id', $batch->pluck('id'))
        ->update(['status' => 'processed']);
}

This reduces DB queries from N (jobs) → 1. I’ve seen 300% throughput gains with this pattern.

The One Thing Everyone Gets Wrong: Job Size

This is the most common production failure I see: a job that looks like this:

PHP
// app/Jobs/ProcessOrder.php

public function __construct(public Order $order)
{
    // ❌ Storing Eloquent models in jobs
}

Why this breaks:

  • Laravel serializes the entire $order model—including relationships
  • If $order->user()->with('posts')->get() loads 50MB of data? Your job payload is 50MB
  • Redis memory spikes. Database locks. Workers timeout.

Fix: Store only IDs, resolve in handle():

PHP
public function __construct(public int $orderId)
{
    // ✅ Store only ID
}

public function handle()
{
    $order = Order::findOrFail($this->orderId);
    // ... process
}

This keeps job payloads < 1KB. Always.

FAQ: Laravel Queues, Answered Honestly

Q1: Should I use queues for everything?

A: No. Queues add latency (jobs process after the request). If a user uploads a file and must wait for validation, do it synchronously. Use queues only for non-blocking tasks.

Q2: My queue worker dies after 2 hours. Why?

A: Check supervisord or systemd logs. Most hosting providers (including Laravel Forge) restart long-running processes. Use --max-jobs=500 to force restarts before timeouts.

Q3: How do I test queue jobs?

A: In tests, use Queue::fake(), but also run php artisan queue:work --once to verify jobs execute end-to-end. Fake tests lie when jobs fail silently.

Q4: Can I run jobs immediately in testing?

A: Yes. Set QUEUE_CONNECTION=sync in .env.testing. But never in production—it blocks requests.

Q5: Do queues work with Laravel Horizon?

A: Yes, but Horizon is overkill for small apps. It adds Redis overhead and complexity. Use it only if you need real-time job metrics or auto-scaling.

Final Thoughts

Laravel queues are powerful—but only if you respect their constraints. The biggest mistake I see is treating them like magic: "just add ->dispatch() and it’ll work."

They don’t. They require:

  • Smarter job payloads (IDs only)
  • Stricter failure handling (no silent retries)
  • Real monitoring (log every job event)

I’ve helped teams scale Laravel apps to 5M+ jobs/month using this pattern. It’s not glamorous, but it works.

If you’re building something that matters, don’t skip the queue fundamentals. Your users (and your ops team) will thank you.

Written by mahbuburriad.com, where Laravel isn’t just a framework—it’s a workflow.

Related

Related posts