Laravel Jobs — Stop Silent Retry Storms
Unlimited retries and no failed_jobs caused a payment queue to loop at 100% CPU, processing zero payments.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
- 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
Imagine you're at a fast-food counter. When you order, the cashier doesn't disappear into the kitchen for five minutes while you wait — they hand your order ticket to the kitchen crew and immediately take the next customer. The kitchen works through those tickets in the background. Laravel Queues are exactly that ticket system: your app hands off slow or heavy work (sending emails, processing images, calling external APIs) to a background 'kitchen crew' called workers, so your HTTP response stays lightning fast. Jobs are the individual tickets.
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.
- 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
$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.Worker Optimization: Key CLI Options
Laravel's queue:work command comes with a dozen CLI options that directly control worker behavior. Misconfiguring these is the most common cause of production queue meltdowns. The table below summarizes the critical flags and their production impact. Always pair these with Supervisor process management for automatic restart and concurrency.
--tries=0 default is a footgun. Always set a finite limit. Combine with --backoff to spread retries. In Supervisor, set numprocs equal to (2 * CPU cores) for I/O-bound workloads or (CPU cores) for CPU-bound. Monitor worker restart frequency with supervisorctl status to detect memory leaks.--sleep for production. --tries, --timeout, and --memory are non-negotiable.Horizontal Scaling: Multiple Workers and Queue Topology
When a single worker can't keep up — you see queue depth growing and jobs taking minutes to process — the solution is horizontal scaling. Laravel supports multiple workers reading from the same queue driver natively. Each worker is an independent PHP process, often managed by Supervisor. You can also run workers on separate servers, all pointing to the same Redis or SQS queue. The diagram below shows a typical production topology with a load balancer distributing traffic, a shared Redis queue, and multiple worker servers consuming jobs.
Key scaling considerations: Workers are stateless and compete for jobs. There's no built-in worker coordination — each worker pops the next available job. This means you can scale out arbitrarily, but you must ensure the queue driver can handle the concurrency. Redis handles high connection counts well; the database driver may hit lock contention. For SQS, set the visibility timeout to at least 5x your maximum job runtime to prevent duplicate processing when workers are scaled up quickly.
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.
$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.Job Middleware: Rate Limiting, Logging, and Custom Guards
Job middleware in Laravel allow you to wrap the execution of a job with custom logic — similar to HTTP middleware. This is useful for cross-cutting concerns like rate limiting, logging start/end times, or checking a circuit breaker before running. You apply middleware by defining a method on the job class that returns an array of middleware objects. Laravel ships with a RateLimited middleware that uses the cache rate limiter to prevent jobs from hitting an API too frequently.
Middleware runs outside the job's method. If middleware throws an exception (e.g., rate limit hit), the job is not executed and will be released back to the queue after a delay. This is more efficient than checking rate limits inside handle() because the job is never actually popped if the rate limit is exceeded — preventing wasted effort.handle()
You can also create custom middleware. For example, a middleware that checks if a maintenance flag is set, or a middleware that logs job execution time to Prometheus.
Illuminate\Queue\Middleware\RateLimited which integrates with the framework's rate limiter. Define a rate limiter in App\Providers\AppServiceProvider and then reference it: return [new RateLimited('backups')];. It handles delays and retries automatically.handle(). Use them for rate limiting, logging, and pre-checks. The built-in RateLimited middleware integrates with the Laravel rate limiter.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.
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.Queue Driver Performance: Throughput and Latency Benchmarks
Choosing the right queue driver for your workload requires understanding real-world performance. The numbers below come from production benchmarks on a standard 8-core VM with 16GB RAM, running 4 workers consuming jobs that do a 50ms sleep (simulating I/O). All drivers were configured with default Laravel settings except where noted.
Key takeaways: Redis delivers the lowest latency and highest throughput for most workloads. Database driver is fine for low-volume applications but bottlenecks quickly under concurrent writes. SQS adds network latency but offers infinite scalability and durability. For CPU-bound jobs (processing-heavy), throughput is limited by CPU, not queue driver.
Use this table to estimate whether your current driver will handle your peak load. If your job arrival rate exceeds the driver's throughput, you'll need to either add more workers (if the driver can handle more connections) or switch to a faster driver.
queue, available_at, reserved_at). Without indexes, database throughput drops to ~100 jobs/sec. Redis memory usage grows linearly with queue depth; a backlog of 100,000 jobs with 2KB payload each consumes 200MB. For SQS, use long polling (WaitTimeSeconds=20) to reduce API costs and latency. Always benchmark with your actual job payload size and processing logic.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.
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.Why You Actually Need Queues (Stop Pretending It's Optional)
Every Laravel app starts simple. You fire off a mail, resize an image, call an API — and your user stares at a spinner for 3 seconds. That's not a feature, it's a bug.
Queues exist to decouple your HTTP response from work that doesn't need to finish right now. Shoving work into a queue drops your response time from 8 seconds to under 100ms. That's the difference between a user bouncing or converting.
Fault tolerance isn't a luxury — it's a production survival skill. When your payment gateway goes down, a queued job retries automatically instead of showing a 500 page. Your users never see the crash.
Scalability is the real payoff. With a Redis-backed queue, you can spin up ten workers on one box, then throw fifty more across three servers when Black Friday hits. No code changes. No downtime. Just raw throughput.
The alternative? Your web request thread blocks while you wait for an SMTP handshake. That's amateur hour.
Stop treating queues as advanced architecture. They're the baseline for any app that touches a network or does more than a SELECT query.
handle() method? That's a filesystem write. On a busy queue, disk I/O stalls cascade into job timeouts. Always push logs to a buffer or a separate async handler.Setting Up Queues Without the Fluff (Database Driver in 60 Seconds)
You don't need Redis to start. The database driver gets you 80% of the value with zero ops overhead. Here's the exact sequence.
Set your env. Run the migration. Create a job. Dispatch it. Done.
The queue:table migration creates a simple table that stores serialized job data. Your worker polls it every few seconds — that's the whole loop.
Most tutorials stop here. They should tell you the critical config: notBeforeSeconds. If you're dispatching a job that needs data from a freshly committed transaction, slap a 2-second delay. Otherwise your worker grabs the job before the database index flushes.
Also: run php artisan queue:work with --tries=3 from day one. No tries means a failed job sits in the table forever, silently eating memory. You will find this at 3 AM after a runaway job fills your disk.
Database queue is fine for 10,000 jobs/day. For 100,000+ jobs/day, switch to Redis. But don't optimise before you measure.
php artisan queue:restart before deploying new code. It sends a SIGTERM to workers, which finish their current job then die. New workers pick up fresh code. No job loss.Dispatching Jobs Like a Pro (Dispatchable, Delay, and Chain Pitfalls)
You dispatch a job with ::dispatch(). But the method you chain after it matters.
returns nothing. You cannot catch its result. It's fire-and-forget — the worker runs it later, on a different process.dispatch()
dispatchIf() and dispatchUnless() let you gate behind booleans. Use them in controller logic to avoid if blocks that make intention unclear.
Delay is not a timeout. Calling ->delay(5) tells Laravel "release this job to the queue 5 seconds from now". It does not fail the job if it runs long. That's a separate retryUntil() method.
Job chaining via Bus::chain() is the hidden gem. Three jobs run in sequence. If step 2 fails, step 3 never fires. Perfect for payment flows: validate, capture, send receipt. Don't chain unrelated jobs; one failure kills the whole chain.
On queue fail — never swallow exceptions in your job's handle() method. That hides errors from Horizon and your monitoring. Let it throw. Let it be caught by the retry logic. You want to know when a job fails, not pretend it succeeded.
handle() — always let them propagate for retries and monitoring.The Silent Retry Storm That Took Down a Payment Queue
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.- Always set a finite retry limit on every job.
- Guard all job
handle()methods against stale or deleted models. - Monitor the queue length and failed job count in production.
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.ps aux | grep 'queue:work'supervisorctl tail laravel-worker stderrKey takeaways
Common mistakes to avoid
4 patternsPassing full Eloquent models to jobs
handle(), query the model fresh using the ID. If the model must be passed, use the SerializesModels trait with deleteWhenMissingModels=true.Forgetting to set a retry limit ($tries) on jobs
Using the database queue driver without proper indexing
Not monitoring queue length or failed job count
Interview Questions on This Topic
How does Laravel's queue system ensure a job is only processed once, even if the worker crashes mid-execution?
handle() method returns without throwing an exception. If the worker crashes (or the job times out), the job remains in the queue (its 'reserved' flag is reset after retry_after seconds). A different worker will pick it up later. This gives at-least-once delivery. To truly guarantee once-and-only-once, you need idempotency in the job itself — e.g., checking a processing status in the database before performing the action.Frequently Asked Questions
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Laravel. Mark it forged?
10 min read · try the examples if you haven't