Introduction
Modern web applications frequently need to perform tasks that are too slow, too resource intensive, or too failure-prone to execute during an HTTP request cycle. Sending transactional emails, generating PDF reports, processing uploaded images, synchronizing with third-party APIs, handling inbound webhooks, and running machine learning inferences are typical examples. If you attempt to run these operations synchronously inside a controller, your users will face latency, gateway timeouts, and a generally poor experience. Laravel queues provide a clean, unified API to defer such work to background processes. The term Laravel queues refers to the built-in queue system that ships with the Laravel framework, allowing developers to dispatch jobs that are later picked up by independent worker processes. In this article we explore how to design, implement, and operate Laravel queues in production. We cover the core concepts, the underlying architecture, a step-by-step setup guide, real-world use cases, production-ready code examples, driver comparisons, best practices, common mistakes, performance tuning, security, deployment, and debugging. Whether you are building a small SaaS product or a large-scale enterprise platform, understanding how to properly use Laravel queues will directly improve the reliability, responsiveness, and scalability of your PHP applications. The queue system is not an add-on; it is a foundational pattern for any non-trivial Laravel project that expects to grow beyond a handful of concurrent users.
When we talk about background processing, we are really talking about decoupling the trigger of an action from its execution. A user clicking "Place Order" should receive an immediate confirmation, while the heavy lifting of payment capture, inventory update, and fulfillment notification happens asynchronously. Laravel makes this possible without requiring external message brokers like RabbitMQ unless you explicitly want them. The framework abstracts the broker behind a simple connection configuration and provides a consistent developer experience whether you use a database table, Redis, Amazon SQS, or Beanstalkd. This abstraction is one of the reasons why Laravel queues are so popular among PHP developers: you can start with the database driver and later migrate to Redis without changing a single line of job logic.
Table of Contents
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
Before writing code, you must understand the vocabulary of the Laravel queue system. A job is a PHP class that encapsulates a unit of work. It typically implements the ShouldQueue interface and contains a handle method where the business logic lives. A queue is a named pipe or list inside a backing store (such as Redis or a database table) that holds serialized job payloads. A worker is a long-running Artisan command (php artisan queue:work) that connects to the backing store, pops jobs, and executes them inside a freshly booted framework environment.
Laravel supports multiple connections and queue names. A connection defines which driver and backend to use, while a queue name lets you prioritize or isolate workloads. For example, you might push billing events to a high queue and analytics to a low queue. The retry mechanism automatically re-enqueues a job if it throws an exception, using an exponential or custom backoff. A timeout kills a worker that runs longer than a configured threshold to prevent memory leaks. Failed jobs are recorded in a failed_jobs table (or another store) for later inspection and manual retry. The job lifecycle moves from pending to reserved (when a worker claims it) to completed or failed. Because the worker acknowledges completion only after success, a crash leaves the job in a recoverable state.
Additional concepts include job middleware (e.g., rate limiting), batches (running a group of jobs and then a callback), chains (sequential jobs), and queued listeners for events. Laravel Horizon is a supervisor and dashboard specifically designed for Redis-backed queues, providing real-time metrics, auto-scaling, and graceful restarts. Understanding these primitives allows you to model complex workflows such as "charge card, then if success send receipt, then update CRM" as a chain of small, testable jobs rather than one monolithic method.
Architecture Overview
A typical production Laravel queue architecture consists of three logical tiers: the producer, the broker, and the consumer. The producer is your Laravel application (web or API node) that dispatches jobs. When you call dispatch(new ProcessOrder($order)), Laravel serializes the job and pushes it to the configured broker. The broker is the queue backend: Redis is the most common choice due to its speed and atomic list operations; Amazon SQS is popular in serverless or AWS-centric environments; a database table works for low-throughput apps. The consumer is a pool of worker processes running on one or more servers. Each worker continuously polls the broker, claims a job, deserializes it, and invokes the handle method.
When using Redis, Laravel leverages the LPUSH/RPOP pattern or the more robust BRPOP blocking pop. Horizon adds a supervisor layer that monitors worker health, restarts dead processes, and exposes a web UI. For fault tolerance, jobs are acknowledged only after successful execution; if a worker crashes, the job remains in the queue (or is retried from a reserved state) and another worker can claim it. This at-least-once delivery model means your job logic must be idempotent. In practice, you should check whether the desired side effect has already happened before performing it. For example, before sending an email, verify the order status is not already "notified".
In containerized deployments, the web container dispatches jobs while separate worker containers subscribe to the same Redis instance. A message bus like Redis Pub/Sub can signal workers to restart after deployment. This decoupled design allows horizontal scaling of consumers independently from HTTP servers. You might run two web pods and ten worker pods, or auto-scale workers based on queue depth using Kubernetes Horizontal Pod Autoscaler or Horizon's auto-balance feature. The broker itself should be treated as a critical infrastructure component: use Redis with AOF persistence or a managed Redis service to avoid losing jobs on restart.
Step-by-Step Guide
Follow these steps to configure Laravel queues in a new or existing project. The instructions assume Laravel 10 or later, but the concepts apply to earlier versions with minor differences.
- Choose a driver: In your
.envsetQUEUE_CONNECTION=redis(ordatabase,sqs). For Redis, also configureREDIS_HOSTandREDIS_PASSWORD. For database, ensure yourconfig/database.phpconnection is correct. - Create the jobs table (if using database driver): Run
php artisan queue:tablethenphp artisan migrate. For failed jobs runphp artisan queue:failed-tableand migrate. Even with Redis, creating the failed_jobs table is recommended so you can inspect errors. - Generate a job class: Run
php artisan make:job ProcessOrder. The file appears inapp/Jobs. The generated class includes the necessary traits. - Implement the job: Add
implements ShouldQueue, definepublic function handle()with your logic. Inject dependencies via method signature or constructor. - Dispatch the job: In a controller or service, call
ProcessOrder::dispatch($order)->onQueue('orders');You may also usedispatch_syncfor testing. - Start a worker: Run
php artisan queue:work redis --queue=orderslocally to test. Use--tries=3and--timeout=60to simulate production constraints. - Install Horizon (recommended for Redis):
composer require laravel/horizon, thenphp artisan horizon:installand configureconfig/horizon.php. Horizon provides aphp artisan horizoncommand that starts supervised workers. - Run in production: Use Supervisor or systemd to keep
php artisan horizonalive. Configure the supervisor program to restart on failure and log output.
After these steps your application can offload work. Monitor the jobs and failed_jobs tables (or Horizon UI) to verify execution. Remember to set the QUEUE_CONNECTION on your worker environment identically to the web environment; mismatches cause jobs to be dispatched to a backend that no worker is reading.
Real-World Examples
Laravel queues shine in many scenarios. In an e-commerce platform, after a customer places an order you can dispatch SendOrderConfirmation, ChargePayment, and NotifyWarehouse jobs. These run asynchronously, so the checkout HTTP request returns in milliseconds. A SaaS application might process CSV exports: the user clicks export, you dispatch GenerateReport, and when done email a download link. Social platforms use queues to resize and moderate uploaded images. Financial systems use them to reconcile transactions with external banks via scheduled jobs. AI-powered features, such as generating embeddings with the OpenAI API, are naturally queued because of rate limits and latency.
Another common pattern is welcome email sequences. When a user registers, dispatch a SendWelcomeEmail job delayed by five minutes, then a SendFeatureTip job delayed by one day. Password reset emails should also be queued to avoid blocking the response. Newsletter distribution to thousands of subscribers is a perfect batch use case: create a batch of SendNewsletter jobs and monitor progress. Video platforms use queues to trigger transcoding workers. In all cases, the pattern is the same: identify slow or failure-prone operations, wrap them in a job, and dispatch. By doing so, you isolate failures; if the email service is down, your checkout still succeeds and the job can retry later.
Production Code Examples
Below are realistic, production-grade snippets that follow current Laravel best practices. They demonstrate explicit retries, timeouts, idempotency, and Horizon configuration.
Job Class with Retries and Timeout
namespace App\Jobs;use Illuminate\Bus\Queueable;use Illuminate\Contracts\Queue\ShouldQueue;use Illuminate\Foundation\Bus\Dispatchable;use Illuminate\Queue\InteractsWithQueue;use Illuminate\Queue\SerializesModels;use App\Models\Order;use App\Mail\OrderShipped;use Illuminate\Support\Facades\Mail;class ShipOrder implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public int $timeout = 120; public array $backoff = [10, 30, 60, 120, 300]; public function __construct(public Order $order) { } public function handle(): void { // Idempotent shipping logic if ($this->order->isShipped()) { return; } Mail::to($this->order->customer_email) ->send(new OrderShipped($this->order)); $this->order->markShipped(); } public function failed(\Throwable $exception): void { logger()->error('ShipOrder failed', [ 'order_id' => $this->order->id, 'error' => $exception->getMessage(), ]); }}The class above uses typed properties and a failed method for observability. The $backoff array defines increasing delays between attempts, which is polite to downstream services experiencing transient issues.
Dispatching with Priority and Chaining
use App\Jobs\ShipOrder;use App\Jobs\GenerateInvoice;use App\Jobs\NotifyCustomer;$order = Order::find(1);ShipOrder::withChain([ new GenerateInvoice($order), new NotifyCustomer($order),])->dispatch($order)->onQueue('high');Chaining ensures that invoice generation only happens after shipping succeeds. If ShipOrder fails permanently, the chained jobs are not executed, preserving data consistency.
Horizon Configuration Snippet
// config/horizon.php (excerpt)'environments' => [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', 'queue' => ['high', 'default', 'low'], 'maxProcesses' => 20, 'balance' => 'auto', 'timeout' => 60, ], ],],This configuration tells Horizon to auto-balance workers across the three queues, prioritizing high. The timeout matches the longest acceptable job duration.
Batch Processing Example
use Illuminate\Bus\Batch;use Illuminate\Support\Facades\Bus;use App\Jobs\ImportRow;$rows = $request->file('csv')->toArray();Bus::batch( collect($rows)->map(fn ($row) => new ImportRow($row)))->then(function (Batch $batch) { // All rows imported})->dispatch();Batches are ideal for bulk imports; you get a progress callback and centralized failure handling.
Comparison Table
Choosing the right queue driver is critical because it affects throughput, operational complexity, and failure modes. The table below compares the most common Laravel queue backends. A database driver writes jobs to a jobs table; it is easy to set up but can become a bottleneck under heavy load due to row-level locking. Redis uses in-memory lists and is extremely fast; with Horizon it becomes a first-class production system. Amazon SQS is a fully managed queue that removes the need to operate a broker, but it introduces AWS costs and slight latency. Beanstalkd is a simple, fast work queue but requires running and monitoring another daemon.
| Driver | Setup Complexity | Throughput | Reliability | Monitoring |
|---|---|---|---|---|
| Database | Low (migration only) | Moderate | Good (ACID) | Basic (Artisan commands) |
| Redis | Low-Medium | Very High | High (with persistence) | Excellent (Horizon) |
| Amazon SQS | Medium (AWS config) | High | Very High (managed) | CloudWatch integration |
| Beanstalkd | Medium (daemon) | High | Moderate | Third-party tools |
For most production workloads, Redis with Horizon offers the best balance of speed, visibility, and operational simplicity. If you are already on AWS and prefer not to manage Redis, SQS is a solid alternative. The database driver is best reserved for low-traffic internal tools.
Best Practices
- Make jobs idempotent: Because of at-least-once delivery, a job may run twice. Check state before acting. For example, verify an email was not already sent by querying a flag on the model.
- Set explicit timeouts and retries: Never rely on infinite retries; use capped
$triesand sanebackoff. A job that always fails should not loop forever. - Use separate queues for priority: Route critical jobs to a
highqueue processed first, and relegate analytics tolow. This prevents a flood of reporting jobs from starving order processing. - Keep job payloads small: Pass IDs instead of entire Eloquent models when possible to reduce serialization size and avoid stale model states. Use the
SerializesModelstrait only when convenient. - Monitor with Horizon: Track throughput, wait times, and failure rates continuously. Set up alerts when failed jobs exceed a threshold.
- Handle failures gracefully: Implement
failed()to alert and log. Consider forwarding failed job IDs to an error tracker like Sentry. - Use job middleware for rate limits: Protect downstream APIs from bursts using
\Illuminate\Queue\Middleware\ThrottlesExceptionsor custom middleware. - Prune periodically: Old failed jobs consume database space. Use
php artisan queue:prune-failedin your scheduler.
Common Mistakes
- Using the
syncdriver in production, which defeats the purpose of backgrounding and still blocks requests. - Writing non-idempotent jobs that cause duplicate charges, duplicate emails, or double inventory deductions.
- Forgetting to run migrations for
jobsandfailed_jobstables, leading to silent failures or missing error records. - Catching exceptions inside
handle()without re-throwing, preventing retries and masking bugs. - Serializing huge objects, causing memory bloat and slow Redis operations, especially when models have many relationships.
- Not setting
--timeouton workers, leading to stuck processes that hold locks indefinitely. - Exposing Horizon dashboard to the public internet without authentication, leaking operational metrics and job names.
- Dispatching jobs inside a database transaction that later rolls back, causing jobs to reference non-existent records.
Performance Tips
To scale Laravel queues, prefer Redis over database for high throughput. Increase the number of worker processes gradually while watching CPU and memory. Use balance=auto in Horizon to distribute load across queues dynamically. Avoid running heavy SQL queries inside jobs without indexes. Batch similar jobs using Bus::batch() to reduce overhead. If jobs call external APIs, use concurrency limits via middleware to avoid throttling. Regularly prune stale failed_jobs and jobs table entries with php artisan queue:prune-failed. Consider using php artisan queue:work --once in serverless environments where the process is ephemeral. For very high volume, partition queues by tenant or entity type to avoid head-of-line blocking. Always monitor the wait_time metric; a rising wait time indicates you need more workers or a faster backend.
Security Considerations
Queue backends often contain sensitive business data. Restrict network access to Redis or SQS using security groups or VPC. Enable authentication on Redis (requirepass) and use TLS for connections. If you use Horizon, protect its route with Horizon::auth callback returning true only for admins. Never unserialize untrusted data; Laravel's queue uses secure serialization but avoid passing raw user input into job constructors without validation. Log job failures but redact PII such as email addresses or payment tokens. Keep your Laravel version updated to receive security patches for the queue component. If you process jobs that handle encryption keys or secrets, inject them at runtime from a secrets manager rather than storing them in the job payload. Finally, ensure that workers cannot be triggered by public endpoints; only the broker should feed them.
Deployment Notes
In production, do not rely on php artisan queue:work launched manually. Use Supervisor, systemd, or Kubernetes Jobs to keep workers alive. Below is a minimal Supervisor configuration for a Horizon worker.
[program:horizon]process_name=%(program_name)scommand=php /var/www/html/artisan horizonautostart=trueautorestart=trueuser=www-dataredirect_stderr=truestdout_logfile=/var/log/horizon.logWhen deploying new code, call php artisan horizon:terminate (or send SIGTERM) to let workers finish current jobs and restart with the new code. For zero-downtime, scale up new worker pods before terminating old ones. In Docker, run a separate command: php artisan horizon service. Ensure your .env on workers matches the web nodes, especially APP_KEY and queue config. Use health checks that verify Redis connectivity. If you use Laravel Forge or Envoyer, they provide built-in hooks to restart queues after deployment. Remember that a worker boots the framework once and keeps it in memory; code changes are not picked up until restart, so automate restarts in your CI/CD pipeline.
Debugging Tips
When a job fails, inspect the failed_jobs table for the exception and stack trace. Use php artisan queue:retry all or specific IDs after fixing bugs. Temporarily run php artisan queue:work --tries=1 --verbose to see real-time output. Add structured logging inside handle() using logger()->info() with job context. With Horizon, watch the metrics panel for rising wait times or memory usage. If jobs are stuck in reserved state, a worker likely timed out; clear with php artisan queue:restart. For local debugging, use Laravel Telescope to replay queued jobs. You can also write a small test command that dispatches a job and then runs queue:work synchronously with --once to step through with Xdebug. Always reproduce failures in staging with the same Redis version and configuration as production.
FAQ
What is the difference between a job and a queue?
A job is a class representing a unit of work, while a queue is the named container (in Redis or DB) that holds jobs waiting to be processed. You dispatch jobs to a queue, and workers pull from that queue.
How do I retry failed jobs?
Failed jobs are stored in the failed_jobs table. Use php artisan queue:retry {id|all} to re-enqueue them. You can also configure automatic retries via the $tries property on the job class.
Can I prioritise certain jobs?
Yes. Assign jobs to different queue names (e.g., high, low) and configure your worker to process high first: php artisan queue:work --queue=high,default. Horizon allows per-supervisor queue lists.
How do I schedule jobs?
Use Laravel's task scheduler (php artisan schedule:work) to dispatch jobs at intervals, or use dispatch()->delay(now()->addMinutes(10)) for delayed execution. You can also use ->later() on the dispatcher.
What happens if a worker crashes mid-job?
With Redis, the job is either re-queued (if not acknowledged) or moved to failed after retries. Using Horizon ensures the supervisor restarts the worker automatically. The job should be written to be safe to re-run.
How can I test queued jobs?
In PHPUnit, use Queue::fake() to assert a job was dispatched, or Bus::assertDispatched. For integration tests, set QUEUE_CONNECTION=sync to run jobs immediately within the test.
How do I scale workers horizontally?
Run multiple worker processes on multiple servers pointing to the same backend. With Horizon, increase maxProcesses or add more supervisor instances behind a load balancer. In Kubernetes, increase the replica count of the worker deployment.
How do I handle third-party rate limits?
Implement job middleware that uses Redis to throttle concurrent requests, or leverage Laravel's built-in throttle middleware on the job class. You can also add sleep() between batches or use exponential backoff on exceptions.
Is it safe to pass Eloquent models to jobs?
Yes, if you use SerializesModels trait, but prefer passing IDs and re-fetching inside handle() to avoid stale state and large payloads. This also makes the job resilient to model changes between dispatch and execution.
Can I use Laravel queues without Redis?
Absolutely. The database driver works out of the box with a migration. SQS and Beanstalkd are also supported. However, for high throughput and Horizon features, Redis is recommended.
What is the difference between a chain and a batch?
A chain runs jobs sequentially; each job starts only after the previous succeeds. A batch runs many jobs concurrently and provides aggregate callbacks when all finish, but does not enforce ordering between individual jobs.
Conclusion
Laravel queues are an indispensable tool for building responsive, resilient PHP applications. By moving slow tasks to background workers, you protect user experience and unlock horizontal scalability. Start by selecting Redis with Horizon, follow the step-by-step setup, apply the best practices outlined above, and continuously monitor your pipelines. For deeper dives, explore our internal guides on Laravel Horizon deployment and Dockerizing Laravel. Implement queues today, and transform your application's architecture from blocking to bulletproof. The small upfront investment in learning the queue system will pay dividends every time your traffic spikes or a third-party API hiccups. Your users will notice the speed, and your on-call engineer will thank you for the graceful failure handling.