Laravel Queues and Jobs: Async Work That Survives Production
Every Laravel app eventually needs background work—sending emails, generating PDFs, syncing with external APIs, processing image uploads. Doing this synchronously in HTTP requests makes users wait and risks timeouts.
Jobs and queues move that work off the request cycle.
Creating your first job
php artisan make:job SendWelcomeEmail
<?php
namespace App\Jobs;
use App\Models\User;
use App\Mail\WelcomeMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 60;
public function __construct(
public User $user,
) {}
public function handle(): void
{
Mail::to($this->user->email)->send(new WelcomeMail($this->user));
}
}
Dispatch it from your controller:
public function store(StoreUserRequest $request)
{
$user = User::create($request->validated());
SendWelcomeEmail::dispatch($user);
return new UserResource($user);
}
The user gets an instant response. The email sends in the background.
Queue configuration
In .env:
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis
For local development, database driver works without Redis:
php artisan queue:table
php artisan migrate
QUEUE_CONNECTION=database
Run the worker:
php artisan queue:work redis --tries=3 --timeout=90
In production, use Supervisor to keep workers alive:
[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
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/storage/logs/worker.log
stopwaitsecs=3600
Job chains and batches
When steps must run in order:
Bus::chain([
new ProcessUploadedVideo($video),
new GenerateThumbnail($video),
new NotifyUserVideoReady($video),
])->dispatch();
For parallel work with a completion callback:
$batch = Bus::batch([
new ResizeImage($photo, 'thumb'),
new ResizeImage($photo, 'medium'),
new ResizeImage($photo, 'large'),
])->then(function (Batch $batch) {
NotifyUserPhotoProcessed::dispatch($batch->id);
})->catch(function (Batch $batch, Throwable $e) {
Log::error('Photo batch failed', ['batch' => $batch->id, 'error' => $e->getMessage()]);
})->dispatch();
Track batch progress in Vue with polling or WebSockets.
Unique and delayed jobs
Prevent duplicate jobs for the same resource:
class SyncInventory implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Product $product) {}
public function uniqueId(): string
{
return 'sync-inventory-' . $this->product->id;
}
}
Delay execution:
SendReminderEmail::dispatch($user)->delay(now()->addHours(24));
Or schedule via Laravel’s scheduler:
// routes/console.php
Schedule::job(new CleanupExpiredSessions)->daily();
Handling failures
Implement failed() on jobs that need cleanup:
public function failed(?Throwable $exception): void
{
$this->user->notify(new EmailDeliveryFailedNotification());
Log::error('Welcome email failed', [
'user_id' => $this->user->id,
'error' => $exception?->getMessage(),
]);
}
Retry with exponential backoff:
public function backoff(): array
{
return [30, 60, 120, 300];
}
Inspect failed jobs:
php artisan queue:failed
php artisan queue:retry all
php artisan queue:flush # delete all failed
Model serialization
Jobs serialize Eloquent models by ID by default. If the model is deleted before the job runs, it throws ModelNotFoundException. Handle gracefully:
public function handle(): void
{
if (! $this->user->exists) {
return;
}
// proceed
}
Or use deleteWhenMissingModels on the job class:
public $deleteWhenMissingModels = true;
Rate limiting external APIs
When jobs call third-party APIs with rate limits:
public function handle(): void
{
Redis::throttle('mailgun-api')
->allow(100)
->every(60)
->then(function () {
// make API call
}, function () {
// could not acquire lock — release back to queue
return $this->release(30);
});
}
Monitoring with Horizon
For Redis queues, Laravel Horizon gives you a dashboard:
composer require laravel/horizon
php artisan horizon:install
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default', 'emails', 'media'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
],
],
],
Run with:
php artisan horizon
Horizon shows throughput, wait times, failed jobs, and lets you retry from the UI.
Testing jobs
Assert jobs were dispatched without running them:
use Illuminate\Support\Facades\Queue;
it('dispatches welcome email on user creation', function () {
Queue::fake();
$this->postJson('/api/users', validUserPayload())->assertCreated();
Queue::assertPushed(SendWelcomeEmail::class, function ($job) {
return $job->user->email === '[email protected]';
});
});
Run synchronously in tests:
// phpunit.xml
<env name="QUEUE_CONNECTION" value="sync"/>
When to queue vs run inline
Queue it:
- Email and notifications
- File processing (images, video, PDF)
- Third-party API calls
- Report generation
- Anything over 500ms
Keep inline:
- Simple database writes
- Cache invalidation
- Operations the user must see succeed or fail immediately
Production checklist
- Use Redis (not database driver) at scale
- Run workers via Supervisor or Horizon
- Set
$tries,$timeout, and$backoffon every job - Monitor failed jobs—alert on queue depth spikes
- Use dedicated queues (
emails,media,default) to isolate slow jobs - Log job failures with context for debugging
Queues turn a fragile synchronous app into one that handles spikes gracefully. Start by moving email sending off the request path—it is the highest-impact first step.