Laravel Queue System: Background Jobs
In modern web applications, some tasks are simply too slow to handle during a regular HTTP request. Sending emails, processing images, generating reports, or calling external APIs can take seconds or even minutes. Making users wait for these operations leads to poor user experience and timeout errors. Enter Laravel's queue system—a powerful solution for deferring time-consuming tasks to background processes.
Laravel's queue system allows you to push jobs onto a queue and process them asynchronously using worker processes. This keeps your web application fast and responsive while heavy lifting happens behind the scenes. Whether you're building a simple blog or a complex SaaS platform, understanding queues is essential for creating scalable Laravel applications.
- Understanding queues, jobs, and workers in Laravel
- Configuring queue drivers: Redis, Database, SQS, and more
- Creating and dispatching jobs with best practices
- Job batching and chaining for complex workflows
- Rate limiting and throttling job execution
- Handling failed jobs and implementing retry strategies
- Monitoring queues with Laravel Horizon
- Production deployment and scaling strategies
Understanding the Queue Architecture
Before diving into code, let's understand how Laravel's queue system works. The architecture consists of three main components: jobs (the work to be done), queues (where jobs wait), and workers (processes that execute jobs).
Jobs are dispatched to a queue storage (Redis, database, etc.), then processed by worker processes running in the background.
Jobs
Self-contained classes that define the work to be done. Each job has a handle() method containing the actual logic.
Queues
Named channels where jobs wait to be processed. You can have multiple queues with different priorities.
Workers
Long-running PHP processes that pull jobs from queues and execute them. Run via php artisan queue:work.
Drivers
Backend storage for queues: Redis (recommended), database, Amazon SQS, Beanstalkd, or sync for local dev.
Configuring Queue Drivers
Laravel supports multiple queue drivers out of the box. Your choice depends on your application's needs, infrastructure, and scale. Let's compare them:
| Driver | Best For | Pros | Cons |
|---|---|---|---|
| Redis | Production apps | Fast, reliable, supports delays/priorities | Requires Redis server |
| Database | Simple apps, getting started | No extra services needed | Slower, adds DB load |
| Amazon SQS | AWS-hosted apps, high scale | Managed, highly scalable | AWS lock-in, costs |
| Sync | Local development/testing | Immediate execution | Blocks request, no async |
Setting Up Redis Queue
Redis is the recommended queue driver for production. Here's how to set it up:
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Optional: Use a dedicated Redis database for queues
REDIS_QUEUE_DB=1
<?php
return [
'default' => env('QUEUE_CONNECTION', 'sync'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // Seconds before job is retried if not acknowledged
'block_for' => null, // Seconds to block while waiting for jobs
'after_commit' => false, // Dispatch after DB transaction commits
],
'database' => [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
],
// Define which jobs go to which queues
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
Set after_commit to true if your jobs depend on database records created in the same request. This ensures jobs are only dispatched after the database transaction commits successfully.
Creating and Dispatching Jobs
Jobs are the heart of Laravel's queue system. Let's create a comprehensive example that demonstrates real-world patterns.
Generating a Job Class
# Create a basic job
php artisan make:job SendWelcomeEmail
# Create a job with the ShouldQueue interface
php artisan make:job ProcessPodcastUpload --queued
Job Class Structure
<?php
namespace App\Jobs;
use App\Models\User;
use App\Mail\WelcomeEmail;
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;
use Illuminate\Support\Facades\Log;
class SendWelcomeEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The number of seconds to wait before retrying the job.
*/
public int $backoff = 30;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public int $maxExceptions = 2;
/**
* The number of seconds the job can run before timing out.
*/
public int $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public User $user,
public string $referralCode = ''
) {}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info("Sending welcome email to {$this->user->email}");
Mail::to($this->user->email)
->send(new WelcomeEmail($this->user, $this->referralCode));
// Update user record
$this->user->update(['welcome_email_sent_at' => now()]);
Log::info("Welcome email sent successfully to {$this->user->email}");
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error("Failed to send welcome email to {$this->user->email}", [
'user_id' => $this->user->id,
'error' => $exception->getMessage(),
]);
// Notify admin of the failure
// Notification::route('slack', config('services.slack.webhook'))
// ->notify(new JobFailedNotification($this, $exception));
}
/**
* Determine the middleware the job should pass through.
*/
public function middleware(): array
{
return [
new \Illuminate\Queue\Middleware\WithoutOverlapping($this->user->id),
];
}
/**
* Get the tags that should be assigned to the job.
*/
public function tags(): array
{
return ['email', 'welcome', 'user:' . $this->user->id];
}
}
Dispatching Jobs
Laravel offers multiple ways to dispatch jobs to the queue:
use App\Jobs\SendWelcomeEmail;
// Method 1: Static dispatch
SendWelcomeEmail::dispatch($user);
// Method 2: Using dispatch() helper
dispatch(new SendWelcomeEmail($user));
// Method 3: Using the Bus facade
use Illuminate\Support\Facades\Bus;
Bus::dispatch(new SendWelcomeEmail($user));
// Dispatch with additional constructor arguments
SendWelcomeEmail::dispatch($user, 'REFERRAL2024');
use Carbon\Carbon;
// Delay by seconds
SendWelcomeEmail::dispatch($user)->delay(60);
// Delay by minutes using Carbon
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10));
// Schedule for a specific time
SendWelcomeEmail::dispatch($user)->delay(
Carbon::tomorrow()->setHour(9)
);
// Delay with backoff strategy (useful for rate-limited APIs)
SendWelcomeEmail::dispatch($user)
->delay(now()->addSeconds(30))
->afterCommit(); // Only dispatch after DB transaction commits
// Dispatch to a specific queue
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Dispatch to a specific connection and queue
SendWelcomeEmail::dispatch($user)
->onConnection('redis')
->onQueue('high-priority');
// Set queue priority in the job class itself
class SendWelcomeEmail implements ShouldQueue
{
public $queue = 'emails'; // Default queue for this job
// Or dynamically set it
public function __construct(public User $user)
{
$this->onQueue($user->isPremium() ? 'priority-emails' : 'emails');
}
}
// Dispatch only if condition is true
SendWelcomeEmail::dispatchIf($user->wants_emails, $user);
// Dispatch unless condition is true
SendWelcomeEmail::dispatchUnless($user->email_verified, $user);
// Dispatch after response is sent to browser
SendWelcomeEmail::dispatchAfterResponse($user);
// Dispatch synchronously (bypass queue)
SendWelcomeEmail::dispatchSync($user);
// Dispatch and forget (no tracking)
dispatch(new SendWelcomeEmail($user))->afterResponse();
Job Chaining and Batching
For complex workflows, Laravel provides powerful primitives for chaining sequential jobs and batching parallel jobs.
Chaining executes jobs in sequence; batching executes jobs in parallel with callbacks when all complete.
Job Chaining
Chain jobs when they must run in a specific order:
<?php
use App\Jobs\ProcessPodcast;
use App\Jobs\GenerateTranscript;
use App\Jobs\NotifySubscribers;
use Illuminate\Support\Facades\Bus;
// Basic chain
Bus::chain([
new ProcessPodcast($podcast),
new GenerateTranscript($podcast),
new NotifySubscribers($podcast),
])->dispatch();
// Chain with error handling
Bus::chain([
new ProcessPodcast($podcast),
new GenerateTranscript($podcast),
new NotifySubscribers($podcast),
])
->onQueue('podcasts')
->onConnection('redis')
->catch(function (Throwable $e) use ($podcast) {
// Handle chain failure
Log::error("Podcast chain failed: {$e->getMessage()}", [
'podcast_id' => $podcast->id
]);
$podcast->update(['status' => 'failed']);
})
->dispatch();
// Dynamic chain based on conditions
$jobs = [new ProcessPodcast($podcast)];
if ($podcast->wants_transcript) {
$jobs[] = new GenerateTranscript($podcast);
}
if ($podcast->notify_subscribers) {
$jobs[] = new NotifySubscribers($podcast);
}
Bus::chain($jobs)->dispatch();
Job Batching
Batches are perfect for processing multiple items in parallel with callbacks:
<?php
use App\Jobs\ProcessImage;
use App\Models\Gallery;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
class GalleryController extends Controller
{
public function processGallery(Gallery $gallery)
{
$jobs = $gallery->images->map(function ($image) {
return new ProcessImage($image);
})->toArray();
$batch = Bus::batch($jobs)
->then(function (Batch $batch) use ($gallery) {
// All jobs completed successfully
$gallery->update([
'status' => 'processed',
'processed_at' => now(),
]);
// Notify user
$gallery->user->notify(new GalleryProcessed($gallery));
})
->catch(function (Batch $batch, Throwable $e) use ($gallery) {
// First batch job failure detected
Log::error("Gallery processing failed", [
'gallery_id' => $gallery->id,
'error' => $e->getMessage(),
'failed_jobs' => $batch->failedJobs,
]);
})
->finally(function (Batch $batch) use ($gallery) {
// Batch finished (success or failure)
$gallery->update([
'batch_id' => null, // Clear batch reference
'jobs_processed' => $batch->totalJobs - $batch->pendingJobs,
'jobs_failed' => $batch->failedJobs,
]);
})
->name("Process Gallery #{$gallery->id}")
->onQueue('image-processing')
->allowFailures() // Continue processing even if some jobs fail
->dispatch();
// Store batch ID for progress tracking
$gallery->update(['batch_id' => $batch->id]);
return response()->json([
'message' => 'Gallery processing started',
'batch_id' => $batch->id,
]);
}
public function checkProgress(Gallery $gallery)
{
$batch = Bus::findBatch($gallery->batch_id);
if (!$batch) {
return response()->json(['error' => 'Batch not found'], 404);
}
return response()->json([
'id' => $batch->id,
'name' => $batch->name,
'total_jobs' => $batch->totalJobs,
'pending_jobs' => $batch->pendingJobs,
'failed_jobs' => $batch->failedJobs,
'progress' => $batch->progress(),
'finished' => $batch->finished(),
'cancelled' => $batch->cancelled(),
]);
}
}
Job batching requires a database table to track batch status. Run php artisan queue:batches-table and php artisan migrate to create it.
Rate Limiting and Throttling
When integrating with external APIs or handling resource-intensive tasks, you often need to limit how fast jobs are processed. Laravel provides several built-in middleware and patterns for this.
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
class SyncToExternalAPI implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public Model $record
) {}
/**
* Get the middleware the job should pass through.
*/
public function middleware(): array
{
return [
// Global rate limit: max 60 jobs per minute across all workers
new RateLimited('external-api'),
// Throttle exceptions: if job fails, wait before retrying
(new ThrottlesExceptions(3, 5)) // 3 attempts, 5 min cooldown
->backoff(5), // Wait 5 minutes after throttling
// Prevent overlapping: only one job per record at a time
(new WithoutOverlapping($this->record->id))
->releaseAfter(60) // Release lock after 60 seconds
->expireAfter(180), // Lock expires after 3 minutes
];
}
public function handle(): void
{
// Your API sync logic here
$this->syncToApi();
}
/**
* Determine the time at which the job should timeout.
*/
public function retryUntil(): DateTime
{
return now()->addHours(24); // Keep retrying for 24 hours
}
}
// Define the rate limiter in AppServiceProvider
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('external-api', function (object $job) {
return Limit::perMinute(60); // 60 jobs per minute
});
// Or with more sophisticated limits
RateLimiter::for('stripe-api', function (object $job) {
return [
Limit::perSecond(10), // 10 per second
Limit::perMinute(100), // 100 per minute
Limit::perHour(1000), // 1000 per hour
];
});
// User-specific rate limiting
RateLimiter::for('user-exports', function (object $job) {
return Limit::perHour(5)->by($job->user->id);
});
}
}
Rate Limiting Strategies
- RateLimited: Global rate limiting based on defined limiters
- ThrottlesExceptions: Exponential backoff on repeated failures
- WithoutOverlapping: Prevents duplicate processing of the same entity
- Skip: Skip job execution under certain conditions
Handling Failed Jobs
Jobs can fail for many reasons: API timeouts, database deadlocks, or unexpected exceptions. Laravel provides robust tools for handling failures gracefully.
<?php
class ProcessPayment implements ShouldQueue
{
// Simple retry count
public int $tries = 5;
// Exponential backoff: wait 10s, 30s, 60s between retries
public array $backoff = [10, 30, 60];
// Or use a method for dynamic backoff
public function backoff(): array
{
return [1, 5, 10, 30, 60]; // Increasing delays
}
// Keep retrying until this time
public function retryUntil(): DateTime
{
return now()->addHours(12);
}
// Maximum exceptions before marking as failed
public int $maxExceptions = 3;
// Timeout for the job
public int $timeout = 120;
// Delete job if model is missing (soft deletes)
public bool $deleteWhenMissingModels = true;
// Jobs that should run only once
public bool $uniqueFor = 3600; // Unique for 1 hour
public function uniqueId(): string
{
return $this->payment->id;
}
}
<?php
class ProcessPayment implements ShouldQueue
{
public function handle(): void
{
try {
$this->processPaymentWithProvider();
} catch (PaymentProviderException $e) {
// Specific exception handling - maybe don't retry
$this->fail($e);
return;
}
// Other exceptions will trigger automatic retry
}
/**
* Handle a job failure after all retries exhausted.
*/
public function failed(Throwable $exception): void
{
// Log the failure
Log::error('Payment processing failed permanently', [
'payment_id' => $this->payment->id,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// Update payment status
$this->payment->update(['status' => 'failed']);
// Notify relevant parties
$this->payment->user->notify(new PaymentFailed($this->payment));
// Alert operations team
Notification::route('slack', config('services.slack.ops_channel'))
->notify(new PaymentFailedAlert($this->payment, $exception));
// Create support ticket
SupportTicket::create([
'type' => 'payment_failure',
'reference_id' => $this->payment->id,
'description' => $exception->getMessage(),
]);
}
}
// Global failed job handler in AppServiceProvider
use Illuminate\Support\Facades\Queue;
Queue::failing(function (JobFailed $event) {
// Log all job failures centrally
Log::channel('failed-jobs')->error('Job failed', [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'job' => $event->job->resolveName(),
'exception' => $event->exception->getMessage(),
]);
});
# List all failed jobs
php artisan queue:failed
# Retry a specific failed job by ID
php artisan queue:retry 5
# Retry all failed jobs
php artisan queue:retry all
# Retry jobs that failed in the last 24 hours
php artisan queue:retry all --range=0-24
# Delete a specific failed job
php artisan queue:forget 5
# Delete all failed jobs
php artisan queue:flush
# Prune old failed jobs (keep last 48 hours)
php artisan queue:prune-failed --hours=48
# Add to scheduler for automatic cleanup
// app/Console/Kernel.php
$schedule->command('queue:prune-failed --hours=168')->daily();
Monitoring with Laravel Horizon
Laravel Horizon provides a beautiful dashboard and powerful configuration for your Redis queues. It gives you real-time insights into job throughput, runtime, and failures.
Install Horizon
Install via Composer and publish configuration: composer require laravel/horizon then php artisan horizon:install
Configure Supervisors
Define worker pools in config/horizon.php with different queues, processes, and balancing strategies.
Start Horizon
Run php artisan horizon to start all configured workers. Access the dashboard at /horizon.
Deploy with Supervisor
Use Supervisor to keep Horizon running in production and restart it after deployments.
<?php
return [
'domain' => env('HORIZON_DOMAIN'),
'path' => 'horizon',
// Redis connection to use
'use' => 'default',
// Prefix for Horizon keys in Redis
'prefix' => env('HORIZON_PREFIX', 'horizon:'),
// Dashboard route middleware
'middleware' => ['web', 'auth', 'can:viewHorizon'],
// Queue worker configuration
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto', // auto, simple, or false
'autoScalingStrategy' => 'time', // time or size
'maxProcesses' => 10,
'maxTime' => 0,
'maxJobs' => 0,
'memory' => 128,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
],
'environments' => [
'production' => [
// High priority jobs - always process immediately
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high', 'notifications'],
'balance' => 'false', // Fixed number of workers
'processes' => 5,
'tries' => 3,
'timeout' => 30,
],
// Default queue with auto-scaling
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 10,
'balanceMaxShift' => 1, // Max processes to add/remove at once
'balanceCooldown' => 3, // Seconds between scaling decisions
],
// Low priority, long-running jobs
'supervisor-low' => [
'connection' => 'redis',
'queue' => ['low', 'reports', 'exports'],
'balance' => 'auto',
'minProcesses' => 1,
'maxProcesses' => 3,
'tries' => 1,
'timeout' => 1800, // 30 minutes
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'low'],
'balance' => 'false',
'processes' => 3,
],
],
],
];
Protect your Horizon dashboard in production! Define a viewHorizon gate in AuthServiceProvider:
Gate::define('viewHorizon', function ($user) {
return in_array($user->email, ['admin@example.com']);
});
Production Deployment
Running queues in production requires careful attention to process management, monitoring, and deployment procedures.
Supervisor Configuration
[program:horizon]
process_name=%(program_name)s
command=php /var/www/app/artisan horizon
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/horizon.log
stopwaitsecs=3600
stopsignal=SIGTERM
; For queue:work without Horizon
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
Deployment Script
#!/bin/bash
# Put application in maintenance mode
php artisan down
# Pull latest code
git pull origin main
# Install dependencies
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and rebuild caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Restart Horizon gracefully (finish current jobs, then restart)
php artisan horizon:terminate
# Or for queue:work, restart via Supervisor
# supervisorctl restart laravel-worker:*
# Bring application back online
php artisan up
echo "Deployment complete!"
Always use horizon:terminate or queue:restart to gracefully stop workers. Forcefully killing workers (SIGKILL) can corrupt jobs mid-execution and leave your data in an inconsistent state.
Production Checklist
Queue Production Checklist
- ☐ Process Manager: Supervisor or systemd keeping workers alive
- ☐ Monitoring: Horizon dashboard or external monitoring (Datadog, NewRelic)
- ☐ Alerting: Alerts for queue backlogs, high failure rates, and worker deaths
- ☐ Failed Job Handling: Strategy for retrying or investigating failed jobs
- ☐ Log Rotation: Prevent worker logs from filling up disk
- ☐ Memory Limits: Set
--memoryflag to restart workers before OOM - ☐ Timeout Configuration: Appropriate timeouts for different job types
- ☐ Graceful Restarts: Deployment scripts use
horizon:terminate - ☐ Redis Persistence: Configure Redis AOF/RDB for queue durability
- ☐ Backup Strategy: Regular backups of failed_jobs table
Best Practices and Tips
Job Design Best Practices
- Keep jobs small and focused: One job = one responsibility. Break complex workflows into chains.
- Make jobs idempotent: Jobs might run multiple times. Design them to produce the same result regardless.
- Pass IDs, not objects: Serialize model IDs and refetch in
handle()to get fresh data. - Handle missing models gracefully: Use
$deleteWhenMissingModels = trueor check existence. - Set appropriate timeouts: Know how long your job takes and set timeout accordingly.
- Use typed properties: PHP 8+ typed properties make job payloads clear and validated.
Queue Organization Strategies
- Separate queues by priority:
high,default,lowwith different worker counts. - Separate queues by type:
emails,exports,notificationsfor different SLAs. - Separate queues by resource: CPU-intensive vs I/O-bound jobs on different workers.
- Use queue priority workers:
queue:work --queue=high,default,lowprocesses high first.
Common Pitfalls to Avoid
- Serializing large payloads: Don't pass entire collections or large files in job constructors.
- Not handling exceptions: Uncaught exceptions cause silent failures. Use try/catch and
failed(). - Ignoring memory leaks: Long-running workers accumulate memory. Set
--max-jobsor--max-time. - Forgetting queue:restart: After code changes, workers still run old code until restarted.
- Not monitoring queue depth: Backlogs can grow silently. Set up alerts for queue size.
Conclusion
Laravel's queue system is a powerful tool for building scalable, responsive applications. By offloading time-consuming tasks to background workers, you can keep your web responses fast while handling complex operations asynchronously.
Key takeaways from this guide:
- Start simple: Use the database driver for development, then switch to Redis for production.
- Design for failure: Jobs will fail. Use retries, failed handlers, and monitoring to recover gracefully.
- Scale with purpose: Use multiple queues, priorities, and workers based on your actual workload patterns.
- Monitor actively: Laravel Horizon or custom monitoring helps you catch problems before users notice.
- Deploy carefully: Always gracefully terminate workers during deployments to avoid data corruption.
With these patterns and practices, you're equipped to handle everything from simple email notifications to complex data processing pipelines. Happy queuing!
