Introduction
In today's web landscape, user experience is often dictated not by the features you expose, but by how quickly those features respond. Long‑running tasks—such as sending emails, generating PDFs, processing images, or syncing data with external services—can cripple request latency if performed synchronously. Laravel solves this problem with a robust queue system that lets you defer work to background processes called jobs. This article takes you from the fundamentals of Laravel queues to advanced scaling techniques using Laravel Horizon and multiple worker processes.
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
Laravel's queue subsystem revolves around three primary abstractions:
- Jobs – Plain PHP classes that encapsulate a single unit of work. Jobs implement the
Illuminate\Contracts\Queue\ShouldQueueinterface, which tells the framework to push the job onto a queue instead of executing it immediately. - Queues – Logical named containers where jobs are stored until a worker picks them up. Laravel supports many drivers (database, Redis, Amazon SQS, Beanstalkd, etc.), each offering different trade‑offs for durability, speed, and cost.
- Workers – Long‑running CLI processes started with
php artisan queue:work(orqueue:listen) that continuously poll a queue, fetch jobs, and run theirhandle()method.
When you dispatch a job, Laravel serializes the job class, pushes the payload onto the chosen queue driver, and returns immediately to the HTTP request cycle. One or more workers then deserialize the payload, execute the job, and delete the payload on success or release it back onto the queue on failure.
Architecture Overview
The diagram below illustrates a typical Laravel queue architecture for a production environment.
+-------------------+ +-------------------+ +-------------------+| Web Server (Nginx) | --> | Laravel App (PHP‑FPM) | --> | Redis (Queue) |+-------------------+ +-------------------+ +-------------------+ | | php artisan horizon v +---------------------------+ | Horizon Dashboard (Web) | +---------------------------+ | v +---------------------------+ | Queue Workers (Supervisor) | +---------------------------+Key points:
- The web server handles incoming HTTP requests and quickly dispatches jobs to Redis.
- Redis acts as an in‑memory, persistent queue store. It offers sub‑millisecond latency and native data structures for reliable job tracking.
- Supervisor (or systemd) keeps a pool of
php artisan queue:work redisprocesses alive, automatically restarting them on crash. - Laravel Horizon provides a beautiful UI, metrics, and auto‑scaling capabilities for Redis queues.
Step‑By‑Step Guide
Below is a practical guide for setting up Laravel queues with Redis, creating a job, and running workers under Supervisor.
- Install Redis and the PHP extension
Verify Redis is running:sudo apt-get update && sudo apt-get install -y redis-serverpecl install redis && echo "extension=redis.so" | sudo tee /etc/php/8.2/cli/conf.d/20-redis.iniredis-cli pingshould returnPONG. - Configure Laravel
In
.envset the queue driver:
RunQUEUE_CONNECTION=redisREDIS_HOST=127.0.0.1REDIS_PASSWORD=nullREDIS_PORT=6379php artisan config:cacheto refresh config. - Generate a job class
Thephp artisan make:job SendWelcomeEmail --queued--queuedflag automatically adds theShouldQueueinterface. - Implement the job logic
userId = $userId; } public function handle() { $user = \App\Models\User::findOrFail($this->userId); Mail::to($user->email)->send(new WelcomeMail($user)); }}?> - Dispatch the job from a controller
use App\Jobs\SendWelcomeEmail;public function register(Request $request){ $user = User::create($request->only(['name','email','password'])); SendWelcomeEmail::dispatch($user->id); return response()->json(['message' => 'User created, email queued.']);} - Start a worker
For production, you will want a process monitor. Below we configure Supervisor.php artisan queue:work redis --sleep=3 --tries=3 - Configure Supervisor
Run[program:laravel-queue]process_name=%(program_name)s_%(process_num)02dcommand=php /var/www/laravel/artisan queue:work redis --sleep=3 --tries=3 --daemonautostart=trueautorestart=trueuser=www-datanumprocs=4redirect_stderr=truestdout_logfile=/var/log/laravel/queue.logsudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start laravel-queue:*. - Install Horizon (optional but recommended)
Horizon will now monitor your Redis queues, offering retry dashboards and auto‑scaling.composer require laravel/horizonphp artisan horizon:installphp artisan migratephp artisan horizon
Real‑World Examples
Below are three scenarios where queues dramatically improve reliability.
- Bulk Email Campaigns – Instead of looping over 10,000 recipients in a single request, dispatch a
SendCampaignEmailjob for each recipient. Workers parallelize the work, and failures are isolated per‑email. - Image & Video Processing – When a user uploads media, store the raw file, then queue a
ProcessMediajob that runs FFmpeg or ImageMagick in the background. This keeps the upload endpoint snappy. - Third‑Party API Synchronization – Rate‑limited APIs (e.g., Stripe, Twilio) can be wrapped in queued jobs with exponential back‑off, preventing HTTP timeout errors.
Production Code Examples
These snippets demonstrate idiomatic patterns you will encounter in production codebases.
/** * Job that uploads a video to AWS S3 and creates a thumbnail. */class ProcessVideo implements ShouldQueue{ use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public $videoPath; public $userId; protected $tries = 5; protected $backoff = [60, 120, 300]; // seconds between retries public function __construct(string $videoPath, int $userId) { $this->videoPath = $videoPath; $this->userId = $userId; } public function handle() { // 1. Upload original video $s3 = \Storage::disk('s3'); $s3Path = "videos/{$this->userId}/" . basename($this->videoPath); $s3->put($s3Path, file_get_contents($this->videoPath)); // 2. Generate thumbnail using FFmpeg (executed via Symfony Process) $process = new \Symfony\Component\Process\Process([ 'ffmpeg', '-i', $this->videoPath, '-ss', '00:00:01.000', '-vframes', '1', '-f', 'image2', '-' ]); $process->mustRun(); $thumbnail = $process->getOutput(); $thumbPath = "thumbnails/{$this->userId}/" . pathinfo($this->videoPath, PATHINFO_FILENAME) . '.jpg'; $s3->put($thumbPath, $thumbnail); // 3. Update DB record \App\Models\Video::where('path', $this->videoPath) ->update(['s3_path' => $s3Path, 'thumbnail_path' => $thumbPath]); }}Comparison Table
| Feature | Database Driver | Redis Driver | SQS Driver |
|---|---|---|---|
| Persistence | Durable (SQL transaction) | In‑memory with optional RDB snapshot | Fully durable (AWS) |
| Latency | ~10‑20 ms | ~1‑2 ms | ~30‑50 ms (network) |
| Scalability | Limited by DB I/O | Horizontal scaling via clustering | Unlimited via SQS queues |
| Visibility Timeout | Supported | Supported | Supported (default 30 s) |
| Dashboard | None (custom) | Laravel Horizon | AWS Console |
Best Practices
- Keep jobs small – A job should do one logical unit of work. Split large tasks into multiple jobs using
dispatchAfterResponseor job chaining. - Idempotency – Ensure the
handle()method can be safely retried without side‑effects. Use database unique constraints where possible. - Use rate‑limited queues – Laravel's
RateLimitedmiddleware can throttle jobs to respect external API limits. - Monitor with Horizon – Set up alerts for failed jobs, long‑running jobs, and queue depth.
- Graceful shutdown – Send
SIGTERMto workers and let them finish the current job before exiting. Horizon handles this automatically.
Common Mistakes
- Dispatching jobs without implementing
ShouldQueue, causing them to run synchronously. - Running
queue:listenin production; it boots the entire framework on each loop, leading to memory leaks. - Storing large blobs (e.g., entire file contents) in the job payload. Use storage paths instead.
- Neglecting
triesandtimeoutvalues, which can cause hung workers. - Not configuring
supervisorcorrectly, resulting in workers being killed after a short idle period.
Performance Tips
- Prefer Redis over the database driver for high‑throughput queues.
- Enable
php artisan queue:work --daemon(default) to avoid booting the framework on every job. - Set
--memory=128to recycle workers after they exceed a memory threshold. - Leverage Horizon's auto‑scaling: configure
maxProcessesandminProcessesbased on queue length. - Batch jobs using
dispatchBatchto get a single job identifier for monitoring.
Security Considerations
- Never trust data deserialized from the queue without validation. Laravel automatically encrypts the payload, but business‑level validation is still required.
- Limit the queue connection credentials in
.envand rotate them periodically. - Use Laravel's
Queue::afterandQueue::failingevents to log audit trails of job execution. - When using third‑party services, store API keys in Laravel's secret vault (e.g.,
php artisan secret:store) and inject them at runtime.
Deployment Notes
Deploying a queue‑enabled Laravel application typically involves three steps:
- Provision a Redis instance (managed or self‑hosted). Ensure persistence (RDB + AOF) is enabled for durability.
- Deploy the Laravel codebase (zero‑downtime tools like Envoy or Laravel Forge).
- Start and monitor workers with Supervisor or systemd. Example systemd unit:
[Unit]Description=Laravel Queue WorkerAfter=network.target[Service]User=www-dataGroup=www-dataRestart=alwaysExecStart=/usr/bin/php /var/www/laravel/artisan queue:work redis --sleep=3 --tries=3 --timeout=90[Install]WantedBy=multi-user.targetEnable the unit with sudo systemctl enable laravel-queue && sudo systemctl start laravel-queue. For high traffic, scale out the number of ExecStart processes or use Horizon's php artisan horizon which already spawns multiple workers under a single process tree.
Debugging Tips
- Failed job table – Run
php artisan queue:failed-tableandphp artisan migrate. Then inspectfailed_jobsfor exception messages. - Horizon metrics – Use
php artisan horizon:metricsto export stats to Prometheus. - Queue:listen vs queue:work – If you need to test a job instantly, use
queue:listen --onceto process a single job and exit. - Redis CLI – Run
redis-cli LLEN queues:defaultto see pending job count. - Logging – Inside a job, use
Log::info('Processing user', ['id' => $this->userId]);. Logs will appear in your Laravel log file and can be aggregated with tools like Laravel Telescope.
FAQ
What is the difference between queue:work and queue:listen?
queue:work boots the framework once and re‑uses the same PHP process for each job, which is far more memory‑efficient. queue:listen boots the entire framework for every job, making it suitable only for local development.
Can I use multiple queue connections simultaneously?
Yes. You can specify the connection when dispatching: MyJob::dispatch()->onConnection('redis'). Laravel will route the job to the appropriate driver.
How does Horizon auto‑scaling work?
Horizon monitors queue length and adjusts the number of worker processes based on the scale configuration in config/horizon.php. You define thresholds like ['processes' => 5, 'memory' => 128] for each queue.
What happens if a job exceeds its timeout?
The worker will terminate the job process and mark the job as failed, moving it to the failed_jobs table if configured. You can catch this by defining a failed() method on the job class.
Is it safe to store Eloquent models directly in a job?
Storing the entire model can cause large payloads and serialization issues. Prefer passing the model's primary key and re‑fetching inside handle(). If you must pass the model, use SerializesModels trait which only stores the identifier.
How do I retry a job after a transient API error?
Throw an exception in handle(). Laravel will automatically release the job back onto the queue after the configured back‑off period. You can customize back‑off using the backoff() method.
Can I delay a job?
Yes. Use MyJob::dispatch()->delay(now()->addMinutes(10)); or set a $delay property on the job class.
Do I need to run queue:restart after code changes?
When you deploy new code, run php artisan queue:restart. This signals all workers to gracefully exit after completing their current job, allowing the next worker to boot with the updated code.
Conclusion
Laravel queues transform slow, blocking operations into reliable, asynchronous workflows that scale from a single‑server hobby app to a multi‑region enterprise system. By mastering jobs, workers, Redis, and Horizon, you gain fine‑grained control over throughput, latency, and fault tolerance. Implement the steps outlined above, follow the best practices, and continuously monitor your queues. Your users will notice faster response times, and your codebase will become more resilient.Ready to put queues into production? Start by installing Redis on your development machine, create a simple SendWelcomeEmail job, and watch Horizon's dashboard light up. If you need help migrating an existing Laravel app to use queues, reach out via the contact form on rakibahsan.xyz.