On this page
Laravel 11 Queue Workers: A Practical Guide to Handling Jobs Efficiently
If you’ve ever built a Laravel app that sends emails, processes uploads, or syncs with third-party APIs, you’ve likely run into performance bottlenecks when doing these tasks synchronously.
That’s where queues and queue workers come in — and Laravel 11 makes them easier to configure, monitor, and debug than ever before.
But here’s the thing: reading the docs once and moving on isn’t enough. Queue workers behave differently depending on your environment, job type, and deployment strategy. I’ve spent years debugging failed jobs, zombie workers, and memory leaks in production — and I’ll share exactly what works.
Let’s walk through how Laravel 11 queue workers really work — not just how to run them, but how to own them.
Why Queues Matter (Even If You’re Not Building a SaaS)
You might think: "I only have 500 users. Do I really need queues?"
Yes. Even a small app suffers from slow HTTP responses when synchronous jobs block the request thread. Sending a welcome email shouldn’t delay page rendering. Generating a report shouldn’t time out because of a slow API call.
Queues decouple work from the request lifecycle. Laravel 11 supports multiple drivers out of the box:
| Driver | Use Case | Pros | Cons |
|---|---|---|---|
database |
Small to mid apps, simple deployments | No extra service needed; easy to inspect jobs | Slower than Redis at scale; locking issues if not configured |
redis |
Production, high-traffic apps | Fast, reliable, supports delayed jobs & rate limiting | Requires Redis server; overhead for tiny apps |
sync |
Local dev only | Instant execution (no queue) | Never use in production — blocks request |
database:bulk |
Bulk imports (Laravel 11.25+) | Optimized for inserting 10k+ jobs at once | Not for regular runtime jobs |
💡 Pro Tip: Use
syncin.env.testingandredisin.env.production. Never commitsyncto production config.
Setting Up Laravel 11 Queues: Beyond the Basics
Assume you’ve already configured your .env:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Now run:
php artisan queue:table
php artisan migrate
But wait — before you start the worker, configure timeouts and memory limits. This is where most projects fail silently.
⚠️ Critical Config: config/queue.php
Laravel 11 ships with sensible defaults — but they’re not production-sensible.
Open config/queue.php and adjust these:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // ← Increase from 90 to match longest job + 30s buffer
'after_commit' => false, // ← Set to true if using Laravel 11+ with DB transactions
],
🔥 Real Talk:
retry_aftermust be longer than your longest job. If a job takes 70 seconds, set it to 110+ — otherwise, Laravel assumes it crashed and retries it before the original finishes. Duplicate work. Broken state. Pain.
Running the Queue Worker: The Right Way
The canonical command is:
php artisan queue:work
But in production, you should always run it with options:
php artisan queue:work --once --timeout=60 --memory=128
Let’s unpack why:
--once: Runs one job and exits. Great for testing or cron-based dispatch. Not for long-running daemons.--timeout=60: Force-terminates jobs that run too long. Prevents zombie workers.--memory=128: Restarts the worker after it hits 128MB. Crucial for apps with memory leaks (yes, even in PHP).
For Production: Supervisor is Non-Negotiable
Laravel’s docs mention php artisan queue:work --daemon, but don’t use it alone. Daemon mode keeps the worker running — but if it crashes, nothing restarts it.
Use Supervisor instead. Here’s a real config from one of my projects (/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 --tries=3 --max-jobs=1000 --max-time=3600 --memory=256
autostart=true
autorestart=true
numprocs=4
user=www-data
stdout_logfile=/var/www/html/storage/logs/worker.log
stderr_logfile=/var/www/html/storage/logs/worker_err.log
Key flags explained:
--tries=3: Retry failed jobs 3 times before marking them as failed.--max-jobs=1000: Restart worker after processing 1000 jobs. Prevents memory bloat.--max-time=3600: Restart worker every hour. Ideal for daily cache warmups.numprocs=4: Spawn 4 workers for parallel processing.
🛠️ Debugging Tip: After deploying, check
supervisorctl status. If a worker isFATAL, checkstderr_logfile— 80% of issues are missing extensions (e.g.,redisPHP extension).
Job Failures: Not All Failures Are Equal
Laravel 11 improved job failure handling. Now, failed jobs go to failed_jobs table — but how you handle them matters.
1. Use retry_until for Time-Bound Jobs
// app/Jobs/SyncWithExternalAPI.php
public function retryUntil(): ?DateTime
{
return now()->addMinutes(15);
}
This tells Laravel: “Keep retrying this job for 15 minutes — then give up.” Better than blind retries.
2. Handle Failures Gracefully
In your job’s failed() method:
public function failed(
\Throwable $exception
): void {
// Log with context
info('Sync job failed for user ' . $this->userId, [
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Notify via Slack
dispatch(new NotifyAdminJob(
"User {$this->userId} sync failed: {$exception->getMessage()}"
));
}
Never rely on failed_jobs table alone — build failure alerts into your workflow.
3. Purge Old Failed Jobs
Run this monthly:
php artisan queue:flush
Or automate it in app/Console/Kernel.php:
protected function schedule(Schedule $schedule)
{
$schedule->command('queue:flush --hours=24')->weekly();
}
Monitoring & Debugging: Your Production Safety Net
A. Check Queue Length
php artisan tinker
>>> Redis::scard('queues:default'); // For Redis
>>> DB::table('jobs')->count(); // For database
B. Listen to Queue Events
In AppServiceProvider:
use Illuminate\Support\Facades\Event;
use Illuminate\Queue\Events\JobProcessing;
Event::listen(function (JobProcessing $event) {
info("Processing job: " . get_class($event->job->resolve()));
});
This logs job types as they run — invaluable for spotting unexpected payloads.
C. Use Horizon (Optional but Powerful)
If you’re on Redis, Laravel Horizon gives you a dashboard for:
- Job throughput & latency
- Failed jobs
- Worker metrics
- Auto-scaling
Install:
composer require laravel/horizon
php artisan horizon:install
Then run:
php artisan horizon
📌 Reality Check: Horizon adds Redis overhead. For simple apps, it’s overkill. For high-volume systems (10k+ jobs/day), it’s worth the complexity.
Common Pitfalls (And How to Avoid Them)
❌ 1. Forgetting --max-jobs or --max-time
Result: Worker memory grows over time → OOM kill → jobs stall.
Fix: Always set --max-jobs=1000 or --max-time=3600.
❌ 2. Using sync in production .env
Result: All jobs block HTTP requests → 5xx timeouts → angry users.
Fix: Use .env.production with QUEUE_CONNECTION=redis. Double-check with php artisan config:cache.
❌ 3. Ignoring Database Lock Contention (for database driver)
Result: Jobs stuck in reserved state — not processing.
Fix: Add retry_after + use database:bulk for seeding, not runtime.
❌ 4. Not Testing Queue Failures Locally
Run this in tinker to simulate failure:
dispatch(new class extends Job {
use InteractsWithQueue, SerializesModels;
public function handle() { throw new \Exception('Test'); }
});
Then run php artisan queue:work --once. Check failed_jobs table.
Laravel 11’s New Features That Actually Help
✅ queue:work --stop-when-empty
Stops the worker after processing all pending jobs. Perfect for one-off batch jobs:
php artisan queue:work redis --stop-when-empty
✅ queue:retry:failed --id (Laravel 11.20+)
Retry one failed job by ID:
php artisan queue:retry:failed --id=5
No more queue:retry all → accidental reprocessing of 10k jobs.
✅ Queue::after() Hook
Run logic after every job (success or fail):
Queue::after(function (JobProcessed $event) {
// Update metrics
Redis::incr('jobs.completed');
});
FAQ: Laravel Queue Workers
Q1: Why does my queue worker stop after 30 seconds even with --timeout=60?
A: PHP’s max_execution_time (in php.ini) overrides CLI timeouts. Run php -i | grep max_execution_time and increase it (e.g., 600).
Q2: How do I process jobs in a specific order?
A: Use separate queues:
dispatch((new MyJob())->onQueue('high_priority'));
Then run: php artisan queue:work --queue=high_priority,default
Q3: Can I run workers on different servers?
A: Yes! Just point each to the same Redis/database. Supervisor configs stay identical — just update host and user.
Q4: What’s the safest way to restart workers during deploy?
A: Use supervisorctl restart laravel-worker:*. Never kill -9 — Supervisor won’t restart dead processes.
Q5: Do queued jobs respect database transactions?
A: Only if after_commit is true in config/queue.php. Otherwise, jobs may fire before the transaction commits. Use after_commit => true (Laravel 11.25+) if your jobs depend on DB writes.
Final Thoughts
Laravel 11’s queue system is robust — but its power lies in how you operate it, not the code. The biggest mistake I see isn’t misconfiguration; it’s treating queues as “set and forget.”
Set up alerts, monitor job counts, and test failure paths. Your future self (and on-call engineer) will thank you.
If you’re building something that must scale, consider Horizon — but for 90% of apps, a well-configured Supervisor + Redis setup is all you need.
Thanks for reading — and if you’ve got a queue war story, hit me up on Twitter or check more at mahbuburriad.com.
This post was updated for Laravel 11.25 (October 2024).