Background Job Processing Patterns

Reverend Philip Dec 19, 2025 7 min read

Design reliable background job systems. Handle retries, failures, priorities, and monitoring for queue-based architectures.

Background job processing moves time-consuming tasks out of the request cycle, improving user experience and application reliability. This guide covers patterns for implementing robust background processing in Laravel applications.

Why Background Jobs?

The Request Cycle Problem

User Request → Processing → Response
              (blocking)
              2-30 seconds

User: "Is this thing frozen?"

Moving work to background:

User Request → Queue Job → Response (immediate)
                    ↓
              Worker Process
              (async, no waiting)

What to Process in Background

  • Email sending
  • PDF generation
  • Image processing
  • API calls to external services
  • Report generation
  • Data imports/exports
  • Notifications
  • Search indexing
  • Cleanup tasks

Laravel Queue Basics

Creating Jobs

php artisan make:job ProcessOrderPayment
// app/Jobs/ProcessOrderPayment.php
namespace App\Jobs;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessOrderPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Order $order
    ) {}

    public function handle(PaymentGateway $gateway): void
    {
        $result = $gateway->charge(
            $this->order->customer,
            $this->order->total
        );

        $this->order->update([
            'payment_status' => $result->success ? 'paid' : 'failed',
            'transaction_id' => $result->transactionId,
        ]);
    }
}

Dispatching Jobs

// Dispatch immediately to queue
ProcessOrderPayment::dispatch($order);

// Dispatch with delay
ProcessOrderPayment::dispatch($order)
    ->delay(now()->addMinutes(5));

// Dispatch to specific queue
ProcessOrderPayment::dispatch($order)
    ->onQueue('payments');

// Dispatch to specific connection
ProcessOrderPayment::dispatch($order)
    ->onConnection('redis');

// Conditional dispatch
ProcessOrderPayment::dispatchIf($order->needsPayment(), $order);
ProcessOrderPayment::dispatchUnless($order->isPaid(), $order);

Job Configuration

Retries and Backoff

class ProcessOrderPayment implements ShouldQueue
{
    public int $tries = 3;            // Maximum attempts
    public int $maxExceptions = 2;    // Max exceptions before failing
    public int $timeout = 60;         // Seconds before timeout

    // Exponential backoff between retries
    public function backoff(): array
    {
        return [10, 60, 300]; // 10s, 1m, 5m
    }

    // Or calculate dynamically
    public function backoff(): int
    {
        return $this->attempts() * 60;
    }
}

Unique Jobs

Prevent duplicate jobs:

use Illuminate\Contracts\Queue\ShouldBeUnique;

class SendWelcomeEmail implements ShouldQueue, ShouldBeUnique
{
    public function __construct(
        public User $user
    ) {}

    // Unique based on user ID
    public function uniqueId(): string
    {
        return $this->user->id;
    }

    // Unique lock duration
    public int $uniqueFor = 3600; // 1 hour
}

Rate Limiting

use Illuminate\Queue\Middleware\RateLimited;

class SendNotification implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('notifications')];
    }
}

// In AppServiceProvider
RateLimiter::for('notifications', function ($job) {
    return Limit::perMinute(100);
});

Error Handling

Failed Jobs

class ProcessOrderPayment implements ShouldQueue
{
    public function handle(PaymentGateway $gateway): void
    {
        $result = $gateway->charge($this->order);

        if (!$result->success) {
            // Explicit failure - won't retry
            $this->fail(new PaymentFailedException($result->error));
        }
    }

    public function failed(?Throwable $exception): void
    {
        // Called when job fails permanently
        Log::error('Payment processing failed', [
            'order_id' => $this->order->id,
            'error' => $exception?->getMessage(),
        ]);

        // Notify admin
        AdminNotification::send(
            "Payment failed for order {$this->order->id}"
        );

        // Update order status
        $this->order->update(['status' => 'payment_failed']);
    }
}

Retry Specific Exceptions

class CallExternalApi implements ShouldQueue
{
    // Only retry these exceptions
    public function retryUntil(): DateTime
    {
        return now()->addHours(1);
    }

    public function handle(): void
    {
        try {
            Http::timeout(30)->get('https://api.example.com');
        } catch (ConnectionException $e) {
            // Will retry
            throw $e;
        } catch (ClientException $e) {
            // 4xx errors - don't retry
            $this->fail($e);
        }
    }
}

Job Chaining

Sequential Jobs

use Illuminate\Support\Facades\Bus;

// Jobs run in sequence
Bus::chain([
    new ProcessOrder($order),
    new ChargePayment($order),
    new SendConfirmation($order),
    new NotifyWarehouse($order),
])->dispatch();

// With error handling
Bus::chain([
    new ProcessOrder($order),
    new ChargePayment($order),
    new SendConfirmation($order),
])->catch(function (Throwable $e) use ($order) {
    Log::error('Order chain failed', ['order' => $order->id]);
    $order->update(['status' => 'failed']);
})->dispatch();

Job Batches

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessImage($image1),
    new ProcessImage($image2),
    new ProcessImage($image3),
])
->then(function (Batch $batch) {
    // All jobs completed successfully
    Log::info('Batch completed', ['id' => $batch->id]);
})
->catch(function (Batch $batch, Throwable $e) {
    // First job failure
    Log::error('Batch failed', ['error' => $e->getMessage()]);
})
->finally(function (Batch $batch) {
    // Batch finished (success or failure)
    Notification::send('Batch processing complete');
})
->allowFailures()  // Continue even if some jobs fail
->dispatch();

// Check batch status
$batch = Bus::findBatch($batchId);
echo $batch->progress();      // 75
echo $batch->finished();      // false
echo $batch->failedJobs;      // 1

Queue Workers

Running Workers

# Basic worker
php artisan queue:work

# Specific queue
php artisan queue:work --queue=high,default,low

# Memory limit
php artisan queue:work --memory=512

# Process single job
php artisan queue:work --once

# Timeout per job
php artisan queue:work --timeout=60

# Sleep when no jobs
php artisan queue:work --sleep=3

# Stop when queue is empty
php artisan queue:work --stop-when-empty

Production Worker Management

# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/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/html/storage/logs/worker.log
stopwaitsecs=3600

Laravel Horizon

Redis queue dashboard and worker manager:

// config/horizon.php
return [
    'environments' => [
        'production' => [
            'supervisor-1' => [
                'connection' => 'redis',
                'queue' => ['high', 'default', 'low'],
                'balance' => 'auto',
                'minProcesses' => 1,
                'maxProcesses' => 10,
                'balanceMaxShift' => 1,
                'balanceCooldown' => 3,
            ],
        ],
    ],
];

Queue Patterns

Priority Queues

// Define queue priorities
class SendNotification implements ShouldQueue
{
    public function __construct(
        public Notification $notification
    ) {}

    public function queue(): string
    {
        return match ($this->notification->priority) {
            'urgent' => 'high',
            'normal' => 'default',
            'low' => 'low',
        };
    }
}

// Worker processes high first
php artisan queue:work --queue=high,default,low

Job Middleware

// Prevent overlapping jobs
use Illuminate\Queue\Middleware\WithoutOverlapping;

class SyncInventory implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            new WithoutOverlapping($this->product->id)
                ->expireAfter(300)
                ->dontRelease(),
        ];
    }
}

// Skip if duplicate in progress
use Illuminate\Queue\Middleware\Skip;

class ProcessReport implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            Skip::when($this->reportAlreadyProcessing()),
        ];
    }
}

Throttling External APIs

use Illuminate\Queue\Middleware\ThrottlesExceptions;

class CallExternalApi implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            (new ThrottlesExceptions(10, 5))  // 10 exceptions per 5 minutes
                ->backoff(5),                  // Wait 5 minutes after throttle
        ];
    }
}

Testing Jobs

Faking Queues

use Illuminate\Support\Facades\Queue;

public function test_order_dispatches_payment_job(): void
{
    Queue::fake();

    $order = Order::factory()->create();
    $order->process();

    Queue::assertPushed(ProcessOrderPayment::class, function ($job) use ($order) {
        return $job->order->id === $order->id;
    });
}

public function test_batch_jobs_dispatched(): void
{
    Queue::fake();

    $this->service->processImages($images);

    Queue::assertBatched(function ($batch) use ($images) {
        return $batch->jobs->count() === count($images);
    });
}

Testing Job Logic

public function test_payment_job_charges_customer(): void
{
    $gateway = $this->mock(PaymentGateway::class);
    $gateway->expects('charge')
        ->once()
        ->andReturn(new PaymentResult(success: true));

    $order = Order::factory()->create();
    $job = new ProcessOrderPayment($order);

    $job->handle($gateway);

    $this->assertEquals('paid', $order->fresh()->payment_status);
}

public function test_failed_payment_notifies_admin(): void
{
    Notification::fake();

    $order = Order::factory()->create();
    $job = new ProcessOrderPayment($order);

    $job->failed(new PaymentFailedException('Card declined'));

    Notification::assertSentTo(
        Admin::first(),
        PaymentFailedNotification::class
    );
}

Monitoring

Queue Metrics

// Track job duration
class ProcessOrder implements ShouldQueue
{
    public function handle(): void
    {
        $start = microtime(true);

        // Process...

        $duration = microtime(true) - $start;
        Log::info('Job completed', [
            'job' => class_basename($this),
            'duration_ms' => round($duration * 1000),
        ]);
    }
}

// Dashboard metrics
$metrics = [
    'pending' => Queue::size('default'),
    'failed' => DB::table('failed_jobs')->count(),
    'processed_today' => Cache::get('jobs_processed_today', 0),
];

Alerting

// Alert on queue backup
if (Queue::size('default') > 1000) {
    Alert::send('Queue backup detected');
}

// Alert on failed jobs
Event::listen(JobFailed::class, function ($event) {
    if ($this->isHighPriorityJob($event->job)) {
        Alert::send("Critical job failed: {$event->job->displayName()}");
    }
});

Best Practices

  1. Keep jobs small - Single responsibility, one task per job
  2. Make jobs idempotent - Safe to run multiple times
  3. Serialize minimally - Pass IDs, not full models when possible
  4. Handle failures gracefully - Always implement failed() method
  5. Set appropriate timeouts - Match expected job duration
  6. Use queue priorities - Separate urgent from batch work
  7. Monitor queue health - Track size, failures, processing time
  8. Test job logic - Unit test the handle() method

Conclusion

Background job processing is essential for responsive applications. Laravel's queue system provides everything needed: job creation, retry logic, batching, and monitoring. Start with simple jobs, add proper error handling, implement monitoring, and scale workers as needed. The key is moving any work that doesn't need to complete during the request cycle into the background.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.