Laravel Jobs — Stop Silent Retry Storms
- Queues separate heavy work from HTTP requests, keeping responses fast.
- Worker loop: pop, unserialize, handle, delete or retry — with memory and timeout guards.
- Always pass model IDs, not full models, to avoid stale data bugs.
- Queue system offloads slow work from HTTP requests to background workers
- Each job is a serialized object pushed to a driver (Redis, database, SQS)
- Worker loop: pop job, unserialize, fire handle(), delete on success or retry on failure
- maxAttempts and retry_after config prevent infinite retry loops
- Job batching tracks completion of multiple jobs before running a callback
- Biggest mistake: relying on model state that changes between dispatch and execution
Laravel Queue: Quick Debug Cheat Sheet
Worker not running or stuck
ps aux | grep 'queue:work'supervisorctl tail laravel-worker stderrJob failing silently
php artisan queue:failedphp artisan queue:retry allQueue jobs piling up, performance degrading
php artisan queue:work --queue=high,default --sleep=1 --tries=3supervisorctl status | grep workersProduction Incident
handle() method threw an exception because the order model had been deleted by a separate cleanup process. The job had $tries = null (unlimited retries) and no failed_jobs table was configured, so it retried forever.handle() to bail early if the model no longer exists.handle() methods against stale or deleted models.Monitor the queue length and failed job count in production.Production Debug GuideSymptom → Action guide for common queue failures
ps aux | grep 'queue:work'. Verify queue connection config and that the driver (Redis, DB) is reachable.php artisan queue:failed to list failed jobs. Examine the exception stack trace in the payload.queue names must match. For strict ordering use a single queue worker with --queue=high,default or use the --delay option.Every production Laravel app hits the same wall eventually: a controller action that sends a welcome email, resizes an uploaded avatar, and pings a third-party webhook starts taking 800ms or more. Users feel it, bounce rates climb, and your infrastructure bill quietly grows because slow requests hold PHP-FPM workers hostage. The fix isn't a faster server — it's getting that work out of the request lifecycle entirely.
Laravel's Queue system is the engine that makes that possible. It lets you serialise a 'job' — a self-contained unit of work — push it onto a queue driver (Redis, SQS, database, etc.), and have a long-running worker process pick it up milliseconds later, completely outside any HTTP request. The framework handles serialisation, retry logic, failure tracking, delayed dispatch, job chaining, and concurrent batches out of the box. But like any powerful engine, it has sharp edges that bite teams who don't understand what's happening under the hood.
By the end of this article you'll understand how the queue worker event loop actually works, why model serialisation silently causes stale-data bugs, how to tune concurrency without melting your database, how to use job batching for fan-out workflows, and what to monitor in production so failures surface before your users notice them.
What is Laravel Queues and Jobs?
Laravel Queues and Jobs is the framework's mechanism for deferring time-consuming tasks out of the HTTP request lifecycle. Instead of processing an email send or image resize inline, you package the work into a Job class and dispatch it. The queue worker — a long-running CLI process — pops jobs from a storage backend (Redis, database, Amazon SQS, or Beanstalkd), unserializes them, and executes the method. This keeps your API responses snappy and allows horizontal scaling of background work.handle()
A typical job class looks like this: it defines a method that receives any dependencies via dependency injection. The constructor stores the data needed to run the job later (e.g., the model ID, not the entire model). You dispatch with handle() or dispatch()dispatchIf() and can specify a queue name, delay, or whether it runs on a specific connection. The framework serializes the job instance to JSON using its __sleep/__wakeup or the SerializesModels trait — and it's that serialization that causes the most common production bugs.
<?php namespace App\Jobs; use App\Models\Podcast; use App\Services\AudioProcessor; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; class ProcessPodcast implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $tries = 5; // max attempts public $backoff = [10, 30, 60, 120]; // exponential backoff public $deleteWhenMissingModels = true; public function __construct( public int $podcastId, ) {} public function handle(AudioProcessor $processor): void { $podcast = Podcast::findOrFail($this->podcastId); $processor->transcode($podcast); } }
- The dispatcher posts a ticket (job) to a board (queue).
- Workers grab tickets as fast as they can process them.
- If a ticket fails, it gets moved to a failed pile — not thrown away.
- You can put tickets in priority lanes (queue names) to control order.
SerializesModels reloads the model from the database on handle(). If the model was deleted between dispatch and execution, the job fails with a ModelNotFoundException.$deleteWhenMissingModels = true on jobs using SerializesModels to auto-delete the job when the model is gone.handle().SerializesModels sparingly — know when it reloads.How the Queue Worker Loop Actually Works
You run php artisan queue:work and suddenly jobs start getting processed. What's happening inside? The worker is an event loop that:
- Pops a job from the queue driver (Redis:
BLPOP, database:SELECT FOR UPDATE, SQS:ReceiveMessage). - Unserializes the JSON payload back into a job instance.
- Fires the
Queue.beforeevent. - Calls the job's
method via the container.handle() - If
throws an exception: checks retry count. If within $tries, releases the job back to the queue with a delay ($backoff or retry_after). If exceeded, moves the job tohandle()failed_jobs. - Fires the
Queue.afterevent. - Loops to step 1.
Each iteration is called a 'job processing cycle'. The worker sleeps for the --sleep duration when the queue is empty. The --timeout option sets the maximum seconds allowed for one job to run. If a job exceeds the timeout, the worker kills the process and moves on — the job will be retried later (because it was never deleted).
Crucially, the worker does NOT restart between jobs. That means any memory leaked during a job's accumulates. The handle()--memory option forces a restart when the worker's memory exceeds that limit (in MB). For long-running workers, always set --memory=128 or similar.
// Example: supervisor config to run workers with memory guard // /etc/supervisor/conf.d/laravel-worker.conf [program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php /var/www/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600 --memory=128 numprocs=4 autostart=true autorestart=true user=forge stdout_logfile=/var/log/worker.log stderr_logfile=/var/log/worker-error.log
$job->delete() inside handle() if you manually release jobs — forgetting to delete leads to infinite loops.--queue flag determines priority: --queue=high,default processes 'high' queue jobs first, then 'default'. Workers starve if a high-priority queue never empties.Job Batching: Fan-Out and Aggregation Patterns
Laravel's job batch system lets you dispatch a group of jobs and execute a callback when all of them finish. This is ideal for workflows like: generate 100 report PDFs, then upload a combined archive. Or: process 10,000 records in chunks, then send a notification.
To use batching, define a batchable job that implements ShouldBeUnique — but that's not required. The key is to dispatch the batch via Bus::batch([...])->then(function(Batch $batch) { ... })->catch(...)->finally(...)->dispatch(). The batch ID is stored in the job_batches table. Each job in the batch can read $this->batch() to get the batch instance, and optionally cancel the whole batch by calling $this->batch()->cancel().
Important: batch progress is stored in the database, not in memory. That means if you restart the worker, the batch state persists. However, if the batch table is truncated mid-execution, the batch is lost and callbacks never run.
<?php namespace App\Jobs; use Illuminate\Bus\Batchable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; class ProcessChunk implements ShouldQueue { use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct(public array $recordIds) {} public function handle(): void { // If batch was cancelled, bail early if ($this->batch() && $this->batch()->cancelled()) { return; } // Process each record... foreach ($this->recordIds as $id) { // expensive operation } // Track progress on batch $this->batch()->incrementProgress(count($this->recordIds)); } } // In controller: use Bus; $batch = Bus::batch([ new ProcessChunk(range(1, 1000)), new ProcessChunk(range(1001, 2000)), ])->then(function () { // All chunks processed })->catch(function () { // At least one chunk failed })->finally(function () { // Always runs })->dispatch();
$batch->progressPercentage() inside your callback to show progress to users via polling or WebSockets. The batch object has totalJobs, pendingJobs, failedJobs properties.catch handler, the batch's catch will never fire — the failure just moves the job to failed_jobs.catch callback for production batches. Without it, you won't know when a batch partially fails.cancelled() check inside batchable jobs.catch and finally callbacks.Redis vs Database Queue Driver: Real-World Trade-offs
Laravel offers multiple queue drivers. The two most common for self-hosted apps are database and redis. Each has distinct operational characteristics.
Database driver: Uses a jobs table with SELECT ... FOR UPDATE for atomic pop. Simple to set up — no extra service required. But performance degrades under load because each pop acquires a database lock. For high-throughput apps (thousands of jobs per second), the database quickly becomes the bottleneck. Also, delayed jobs require WHERE available_at <= queries, which can be slow without proper indexing.NOW()
Redis driver: Uses Redis lists and blocking pop (BLPOP) for near-instant job retrieval. Supports priority queues natively via ZADD with score. Delayed jobs are stored in sorted sets and only become available after the score time passes. Redis handles high throughput easily, and with Redis Sentinel or Cluster you get HA. The downside: Redis memory is limited. If jobs pile up and memory fills, Redis starts evicting keys. Configure maxmemory-policy noeviction or use a separate Redis instance for queues.
Hybrid approach: Use Redis for your fast queue and database for your reliable queue. The database driver can fall back if Redis goes down.
'connections' => [ 'redis' => [ 'driver' => 'redis', 'connection' => 'queue', // separate Redis connection 'queue' => 'default', 'retry_after' => 90, 'block_for' => null, 'after_commit' => true, ], 'database' => [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', 'retry_after' => 90, 'after_commit' => true, ], ];
retry_after must be long enough that no job exceeds it. If retry_after is 60s and a job takes 90s, another worker will pick up the same job — creating duplicate processing.block_for controls whether pop blocks until a job arrives. Set to 5 seconds to avoid busy-waiting.retry_after at least 10% higher than your longest job runtime.Production Gotchas: Stale Models, Exceptions, and Monitoring
The most common production queue failures all stem from the same root: the world changes between when you dispatch a job and when a worker processes it.
Stale Models: If you pass a model instance to a job, SerializesModels will reload it from the database on . But if another process deleted that model, you get a handle()ModelNotFoundException. Alternatively, if the model's attributes changed (e.g., user email updated), the job uses stale data. Fix: always pass the model ID and query fresh in . Or use handle()$deleteWhenMissingModels = true to auto-cancel stale jobs.
Unhandled Exceptions: If your job throws an exception you don't catch, the worker will retry according to $tries. Eventually the job goes to failed_jobs. But if you don't have a failed job handler (queue:failed-table migration not run), the job is just lost. Always run php artisan queue:failed-table and set up a listener for Queue::failing() to alert your team.
Worker Crashes: If the worker process is killed (OOM, deploy, supervisor restart) while running a job, that job is considered released — it will be retried after retry_after seconds. If you have multiple workers, another worker may pick it up before the original worker finishes, causing duplicate processing. Mitigate by using a job lock or idempotency key in the job's data.
Queue Length Bloat: Monitor queue size with a tool like Horizon or a custom metric (Laravel Pulse, Prometheus). If a queue grows faster than workers can drain it, you need more workers or a faster driver.
use Illuminate\Queue\Events\JobFailed; use Illuminate\Queue\QueueManager; use Illuminate\Support\Facades\Log; // In boot() method: Queue::failing(function (JobFailed $event) { Log::critical('Job failed', [ 'job' => $event->job->resolveName(), 'exception' => $event->exception->getMessage(), 'payload' => $event->job->getRawBody(), ]); // Send alert to Slack/PagerDuty // e.g., Slack::send("Job {$event->job->resolveName()} failed: {$event->exception->getMessage()}"); });
php artisan queue:restart gracefully tells all workers to restart after finishing their current job. But if a job is in an infinite loop (e.g., failed retries that never exhaust), the restart never completes.queue:work --stop-when-empty for one-off processing (e.g., after deploy) to guarantee no jobs linger.| Feature | Database | Redis | Amazon SQS |
|---|---|---|---|
| Setup effort | Minimal (migration) | Requires Redis server | Requires AWS account |
| Pop latency | ~10ms (lock + query) | ~1ms (in-memory) | ~50ms (HTTP API) |
| Throughput | ~500 jobs/sec | >10000 jobs/sec | ~10000 jobs/sec |
| Delayed jobs | Supported (query based) | Native (sorted set) | Supported (visibility timeout) |
| Job priority | Not built-in | Via sorted sets | Not built-in |
| Atomic pop | SELECT FOR UPDATE | BLPOP | ReceiveMessage (at least once) |
| Persistence | In DB — durable | In memory — risk of loss on restart | In S3 — durable |
| Cost | Only database resources | Redis instance cost | Per request + storage |
🎯 Key Takeaways
- Queues separate heavy work from HTTP requests, keeping responses fast.
- Worker loop: pop, unserialize, handle, delete or retry — with memory and timeout guards.
- Always pass model IDs, not full models, to avoid stale data bugs.
- Set $tries, $backoff, and configure failed_jobs table on every project.
- Redis is faster than database but has memory limits; monitor queue depth and failed jobs.
- Design every job to be idempotent so duplicate execution is safe.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does Laravel's queue system ensure a job is only processed once, even if the worker crashes mid-execution?SeniorReveal
- QWhat is the difference between
php artisan queue:listenandphp artisan queue:work? When would you use each?Mid-levelReveal - QExplain how you would implement a priority queue with Laravel Queues using the Redis driver.SeniorReveal
- QWhat happens inside a worker when a job throws an exception? Walk through the retry flow.Mid-levelReveal
- QHow can you ensure that jobs dispatched inside a database transaction only execute if the transaction commits?SeniorReveal
Frequently Asked Questions
What's the difference between a job and an event listener in Laravel?
An event listener runs synchronously in the same process when the event is fired. A job is a self-contained command object that you push onto a queue to run asynchronously. You can dispatch a job from an event listener to offload long-running tasks, but the listener itself is synchronous. If you implement ShouldQueue on your listener, it becomes a job automatically.
Can I run multiple workers on the same queue with different configurations?
Yes. Each worker process runs independently. They can have different --tries, --timeout, --memory settings. However, they share the same queue driver. If two workers with different timeouts pick up a job, the one with the shorter timeout might time out while the other is still processing, causing duplicate execution. Always keep worker configurations consistent for the same queue.
How do I delay a job by a specific amount of time?
Use the method on the job instance: delay()SomeJob::dispatch()->delay(. Under the hood, the queue driver stores the job with an now()->addMinutes(10))available_at timestamp. The worker will not pick it up until that time passes. For Redis, this uses sorted sets with scores equal to the available_at timestamp.
What should I do if a job fails on production and I need to re-run it after fixing the bug?
First, fix the job's code and deploy. Then run php artisan queue:retry all to re-queue all failed jobs. If you need to retry a specific job, use php artisan queue:retry <job_id>. To inspect the failed job's payload (to see the data at the time of failure), query the failed_jobs table or use php artisan queue:failed.
Is it safe to use `dispatchNow()` in production?
dispatchNow() executes the job synchronously in the current process, bypassing the queue. It's useful for testing or when you need the result immediately (e.g., in a queue worker that's about to shut down). In production, avoid it for long-running tasks — it defeats the purpose of queues. Use it sparingly, and only when you're certain the job won't block the request.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.