L dispatch() QUEUE SendEmail ProcessImage GenerateReport ⚡ WORKER Pending Processing Done
Backend

Laravel Queue System: Background Jobs

Mayur Dabhi
Mayur Dabhi
March 25, 2026
22 min read

A user signs up, and the registration request hangs for four seconds because the controller is talking to an SMTP server. Or someone uploads a gallery, the image resizing runs inline, and nginx gives up with a 504 before PHP is done. The work itself is fine. It just doesn't belong inside the HTTP request.

Laravel's queues let you push that work onto a queue and have separate worker processes pick it up, so the request returns right away. This post covers how the pieces fit together, picking a driver, writing and dispatching jobs, chains and batches, rate limiting, failure handling, Horizon, and the deployment details that tend to bite people in production.

What You'll Learn
  • 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

There are three moving parts: jobs (the work to be done), queues (where jobs wait), and workers (long-running processes that execute them). Your app only ever talks to the queue. It never waits for a worker.

Laravel Queue Architecture Laravel Application Controller / Service dispatch(new Job()) Push Job Queue Storage Redis Database Amazon SQS Beanstalkd Pull Job Queue Workers ⚡ Worker 1 ⚡ Worker 2 ⚡ Worker N Job Lifecycle Pending Processing ✓ Completed ✗ Failed Retry (if attempts left)

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 ships with several drivers. Which one you pick mostly comes down to what infrastructure you already run and how much traffic the queue will see.

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

For most production apps I'd go with Redis. It's fast, and it's the only driver Horizon supports. The setup is a few lines in .env:

.env
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
config/queue.php
<?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',
    ],
];
Production Tip

Set after_commit to true if your jobs depend on database records created in the same request. Otherwise a fast worker can pick up the job before the transaction commits, and you get a ModelNotFoundException (No query results for model [App\Models\User]) for a row that definitely exists a second later.

retry_after vs timeout

Keep the job's $timeout (or the worker's --timeout) a few seconds shorter than the connection's retry_after. If a job runs longer than retry_after, the queue assumes it's lost and hands it to another worker while the first one is still running it. That's how people end up sending the same email twice.

Creating and Dispatching Jobs

A job is a plain class with a handle() method. The example below adds the properties and hooks you'll reach for most often: retries, backoff, timeouts, a failure handler, middleware and tags.

Generating a Job Class

Terminal
# 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

app/Jobs/SendWelcomeEmail.php
<?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

There are a few ways to dispatch, and they mostly differ in where and when the job ends up:

UserController.php
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');
Delayed Dispatch Examples
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
Queue-Specific Dispatch
// 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');
    }
}
Conditional Dispatch
// 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

Once one job turns into several, you need a way to say "run these in order" or "run these together and tell me when they're all done". That's what chains and batches are for.

Job Chaining vs Batching Job Chaining (Sequential) ProcessUpload GenerateThumbs NotifyUser Each job runs after the previous completes Job Batching (Parallel) Start ProcessImage 1 ProcessImage 2 ProcessImage N then() callback finally() cleanup

Chaining executes jobs in sequence; batching executes jobs in parallel with callbacks when all complete.

Job Chaining

Use a chain when each step depends on the previous one. If any job in the chain fails, the rest don't run:

Job Chaining Example
<?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 fit the "process 200 images, then mark the gallery done" case. Jobs run in parallel across workers, and you get callbacks when they finish. Each job class in a batch needs the Illuminate\Bus\Batchable trait, or the dispatch will fail.

Job Batching Example
<?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(),
        ]);
    }
}
Batching Requires Database

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

Twenty workers hitting a third-party API at full speed will get you rate-limited or banned fairly quickly. Job middleware lets you cap how fast jobs run without changing the job logic itself.

Rate Limited Job
<?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 fail. APIs time out, databases deadlock, someone deletes the record the job was about to process. The question is whether you find out from a log or from an angry user, so it's worth setting up retries and failure handling before you need them.

Failed Job Recovery Flow Job Fails Exception thrown Attempts Left? Yes Retry Job after backoff No failed_jobs table / storage Recovery Options • queue:retry {id} • queue:retry all • queue:flush • failed() callback
Retry Configuration Options
<?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;
    }
}
Handling Failures
<?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(),
    ]);
});
Terminal Commands
# 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

Plain queue:work gives you no visibility. You can't easily see how many jobs are waiting, which ones are slow, or what just failed. Horizon adds a dashboard and lets you define worker pools in code, but only for Redis queues.

1

Install Horizon

Install via Composer and publish configuration: composer require laravel/horizon then php artisan horizon:install

2

Configure Supervisors

Define worker pools in config/horizon.php with different queues, processes, and balancing strategies.

3

Start Horizon

Run php artisan horizon to start all configured workers. Access the dashboard at /horizon.

4

Deploy with Supervisor

Use Supervisor to keep Horizon running in production and restart it after deployments.

config/horizon.php
<?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,
            ],
        ],
    ],
];
Horizon Dashboard Access

Outside the local environment, Horizon returns a 403 to everyone until you define a viewHorizon gate. Put it in AuthServiceProvider (or Horizon's own HorizonServiceProvider):

Gate::define('viewHorizon', function ($user) {
    return in_array($user->email, ['admin@example.com']);
});

Production Deployment

Workers are long-running processes, so something has to start them, restart them when they crash, and restart them again on every deploy. Supervisor is the usual choice on a plain VPS.

Supervisor Configuration

/etc/supervisor/conf.d/laravel-horizon.conf
[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

deploy.sh
#!/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!"
Don't Kill Workers Forcefully

Use horizon:terminate or queue:restart so workers finish their current job before exiting. A SIGKILL stops a job halfway through, which can leave your data half-updated. That's also why stopwaitsecs in the Supervisor config should be longer than your longest job.

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 --memory flag 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 = true or 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, low with different worker counts.
  • Separate queues by type: emails, exports, notifications for 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,low processes 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-jobs or --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.

When I'd reach for a queue (and when I wouldn't)

I'd queue anything that talks to a third party (mail, payment webhooks, external APIs), anything CPU-heavy like image or PDF work, and anything the user doesn't need to see finish before the page loads. Those are the cases where a slow or flaky dependency would otherwise become your response time.

I wouldn't queue work whose result the user needs on the very next screen. If they submit a form and expect to see the saved data, doing it inline is simpler and avoids "why isn't it there yet?" bugs. For tiny jobs on a small app, dispatchAfterResponse() is often enough and doesn't need a worker at all.

Also keep in mind what a queue adds: another process to supervise, another place for things to fail silently, and code that now runs out of band, so it can't rely on the session or the current request. If you can't commit to monitoring the queue, a backlog of stuck jobs is worse than a slightly slow request. And if you're starting with the database driver, that's fine for low volume, but plan to move to Redis before the jobs table becomes a source of lock contention.

Laravel Queues Jobs Redis Horizon Background Processing
Mayur Dabhi

Mayur Dabhi

Full Stack Developer specializing in Laravel, React, and building scalable web applications. Passionate about performance optimization and clean code.