Laravel Queues: Stop Making Users Wait
Sending emails, processing uploads, generating reports — all things that slow down your app. Learn to use Laravel queues, create jobs, handle failures, and make your application feel instant while heavy work happens in the background.
The Slow Request Problem
My Laravel app had a feature: when a user placed an order, we needed to:
- Save the order to database
- Send confirmation email to customer
- Send notification to admin
- Send SMS to customer
- Update inventory
- Generate invoice PDF
- Notify warehouse system via API
All in one request. The user clicked “Place Order” and waited… and waited… 8 seconds later, success.
8 seconds! Users thought the site was broken.
The Solution: Do It Later
Most of those tasks don’t need to happen right now. The user just needs to know their order was received.
Queues let you defer work to a background process:
// Before: Slow, synchronous
public function store(Request $request)
{
$order = Order::create($request->validated());
Mail::to($order->user)->send(new OrderConfirmation($order)); // 500ms
Mail::to(config('admin.email'))->send(new NewOrderAlert($order)); // 500ms
$order->user->notify(new OrderPlacedSms($order)); // 800ms
$this->inventoryService->update($order); // 200ms
$this->invoiceService->generate($order); // 1500ms
$this->warehouseApi->notify($order); // 2000ms
return redirect('/orders/' . $order->id); // Total: ~6 seconds
}
// After: Fast, asynchronous
public function store(Request $request)
{
$order = Order::create($request->validated());
ProcessOrderJob::dispatch($order); // Instant - just adds to queue
return redirect('/orders/' . $order->id); // Total: ~100ms
}
The user gets their response instantly. The heavy work happens in the background.
Creating a Job
php artisan make:job ProcessOrderJob
<?php
namespace App\Jobs;
use App\Order;
use App\Mail\OrderConfirmation;
use App\Mail\NewOrderAlert;
use App\Notifications\OrderPlacedSms;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class ProcessOrderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $order;
public function __construct(Order $order)
{
$this->order = $order;
}
public function handle()
{
// All the slow stuff happens here, in the background
Mail::to($this->order->user)->send(new OrderConfirmation($this->order));
Mail::to(config('admin.email'))->send(new NewOrderAlert($this->order));
$this->order->user->notify(new OrderPlacedSms($this->order));
// Could dispatch more jobs for other tasks
UpdateInventoryJob::dispatch($this->order);
GenerateInvoiceJob::dispatch($this->order);
NotifyWarehouseJob::dispatch($this->order);
}
}
The ShouldQueue interface tells Laravel this job should be queued, not run immediately.
Queue Drivers
Laravel supports multiple queue backends:
# Use database (simple, no extra setup)
QUEUE_CONNECTION=database
# Use Redis (fast, recommended for production)
QUEUE_CONNECTION=redis
# Use Amazon SQS
QUEUE_CONNECTION=sqs
# Sync (runs immediately, for testing)
QUEUE_CONNECTION=sync
For the database driver:
php artisan queue:table
php artisan migrate
This creates a jobs table that holds pending jobs.
Running the Queue Worker
Jobs don’t process themselves. You need a worker:
php artisan queue:work
This command runs forever, processing jobs as they come in.
Output:
[2019-08-05 10:30:15] Processing: App\Jobs\ProcessOrderJob
[2019-08-05 10:30:17] Processed: App\Jobs\ProcessOrderJob
[2019-08-05 10:30:17] Processing: App\Jobs\UpdateInventoryJob
[2019-08-05 10:30:17] Processed: App\Jobs\UpdateInventoryJob
For development, run it in a terminal. For production, use Supervisor (more on that later).
Job Attempts and Failures
What if a job fails? Network error, API timeout, whatever.
class ProcessOrderJob implements ShouldQueue
{
public $tries = 3; // Retry up to 3 times
public $timeout = 120; // Max 2 minutes per attempt
public function handle()
{
// ... job logic
}
public function failed(\Exception $exception)
{
// Called when all retries are exhausted
Log::error('Order processing failed', [
'order_id' => $this->order->id,
'error' => $exception->getMessage()
]);
// Alert someone
Mail::to(config('admin.email'))->send(new JobFailedAlert($this->order, $exception));
}
}
Failed jobs go to the failed_jobs table:
php artisan queue:failed-table
php artisan migrate
Manage failed jobs:
# List failed jobs
php artisan queue:failed
# Retry a specific job
php artisan queue:retry 5
# Retry all failed jobs
php artisan queue:retry all
# Delete a failed job
php artisan queue:forget 5
# Clear all failed jobs
php artisan queue:flush
Delayed Jobs
Don’t want a job to run immediately?
// Run in 10 minutes
ProcessOrderJob::dispatch($order)->delay(now()->addMinutes(10));
// Run at a specific time
SendReminderJob::dispatch($user)->delay(now()->addDays(1));
Use case: Send a “How was your order?” email 3 days after delivery.
class SendFeedbackRequestJob implements ShouldQueue
{
public function handle()
{
if ($this->order->delivered_at && $this->order->delivered_at->diffInDays(now()) >= 3) {
Mail::to($this->order->user)->send(new FeedbackRequest($this->order));
}
}
}
// Dispatch when order is marked delivered
SendFeedbackRequestJob::dispatch($order)->delay(now()->addDays(3));
Job Queues (Priorities)
Different jobs have different priorities. Order confirmations are urgent. Report generation can wait.
// High priority
ProcessOrderJob::dispatch($order)->onQueue('high');
// Default priority
GenerateReportJob::dispatch($report)->onQueue('default');
// Low priority
CleanupTempFilesJob::dispatch()->onQueue('low');
Run workers for specific queues:
# Process high priority first, then default, then low
php artisan queue:work --queue=high,default,low
# Only process high priority
php artisan queue:work --queue=high
Chained Jobs
Some jobs must run in sequence:
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessPaymentJob($order),
new UpdateInventoryJob($order),
new GenerateInvoiceJob($order),
new SendConfirmationJob($order),
])->dispatch();
If any job fails, the remaining jobs don’t run.
Batch Jobs
Laravel 5.6 didn’t have native batch support, but you could track multiple jobs:
// Create a batch identifier
$batchId = Str::uuid();
// Dispatch jobs with the batch ID
foreach ($users as $user) {
SendNewsletterJob::dispatch($user, $newsletter)->onQueue('newsletters');
}
// Track progress (manually)
Cache::put("batch_{$batchId}_total", count($users));
Cache::put("batch_{$batchId}_processed", 0);
// In the job
public function handle()
{
// ... send newsletter
Cache::increment("batch_{$this->batchId}_processed");
}
Laravel 8+ has proper batch support. In 5.6, we improvised.
Supervisor: Keeping Workers Alive
In production, you can’t just run php artisan queue:work in a terminal. It needs to run forever, restart if it crashes, and start on server boot.
Supervisor does this.
Install:
apt install supervisor
Create config /etc/supervisor/conf.d/laravel-worker.conf:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/myapp/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/myapp/storage/logs/worker.log
stopwaitsecs=3600
Key settings:
numprocs=4: Run 4 worker processesautorestart=true: Restart if worker diesautostart=true: Start on server bootmax-time=3600: Restart worker every hour (prevents memory leaks)
Start Supervisor:
supervisorctl reread
supervisorctl update
supervisorctl start laravel-worker:*
Monitoring Queues
How do you know if queues are backing up?
Simple: Check queue size
use Illuminate\Support\Facades\Queue;
$size = Queue::size('default');
if ($size > 100) {
Log::warning('Queue backup detected', ['size' => $size]);
}
Better: Laravel Horizon (Redis only)
If you use Redis, Laravel Horizon provides a beautiful dashboard:
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Visit /horizon for real-time queue monitoring, job metrics, and failure management.
Real-World Example: Image Processing
User uploads a profile picture. We need to:
- Save the original
- Create a thumbnail
- Create a medium size
- Optimize all versions
- Upload to S3
// Controller - instant response
public function uploadAvatar(Request $request)
{
$path = $request->file('avatar')->store('avatars/originals');
auth()->user()->update(['avatar_original' => $path]);
ProcessAvatarJob::dispatch(auth()->user(), $path);
return response()->json(['message' => 'Upload successful, processing...']);
}
// Job - runs in background
class ProcessAvatarJob implements ShouldQueue
{
public function handle()
{
$image = Image::make(storage_path('app/' . $this->path));
// Create thumbnail
$thumb = $image->fit(100, 100);
$thumbPath = str_replace('originals', 'thumbnails', $this->path);
Storage::put($thumbPath, $thumb->encode());
// Create medium
$medium = $image->fit(300, 300);
$mediumPath = str_replace('originals', 'medium', $this->path);
Storage::put($mediumPath, $medium->encode());
// Update user
$this->user->update([
'avatar_thumb' => $thumbPath,
'avatar_medium' => $mediumPath,
]);
// Optionally upload to S3
UploadToS3Job::dispatch($this->user);
}
}
User uploads and immediately gets feedback. Image processing happens without them waiting.
What I Wish I’d Known Earlier
-
Start with the database driver. Redis is better but adds complexity. Database queues work fine for most apps.
-
Use Supervisor in production. Never run
queue:workmanually on a server. -
Set reasonable timeouts. Long-running jobs can block workers. Use
$timeoutwisely. -
Monitor queue size. A growing queue means workers can’t keep up.
-
Failed jobs need attention. Set up alerts for the
failed_jobstable.
The Journey Continues
Queues transformed how I thought about web applications. Instead of “everything happens in the request,” I started thinking “what can happen later?”
Users got instant responses. Heavy work happened in the background. The app felt fast even when doing complex things.
But all this power meant more complexity to deploy. Multiple services, workers, supervisors. Time to think about putting it all together.
P.S. — The first time I watched a queue process 500 emails in the background while users kept happily using the app, I understood why “async” is everywhere in modern development. Don’t make users wait for things that don’t need to happen right now.
Saurav Sitaula
Software Architect • Nepal