Introduction

Every non-trivial job needs configuration: how many retries, how long before timeout, what to do on duplicate dispatch, how to rate-limit against a flaky API. Laravel exposes this as public properties and middleware on the job class, so the config lives alongside the work instead of scattered across dispatch sites.

Key Concepts

  • $tries: Maximum attempts before the job is marked failed.
  • $backoff: Seconds to wait between retries (scalar or array for exponential).
  • $timeout: Maximum seconds a single attempt may run.
  • ShouldBeUnique: Interface that takes a deduplication lock keyed by uniqueId().
  • Job middleware: Classes like RateLimited and WithoutOverlapping that wrap handle() with cross-cutting logic.

Real World Context

A payment-charge job that retries 3 times with a backoff of [30, 60, 120] gives the gateway time to recover from a blip. A search-index-update job marked ShouldBeUnique means a burst of edits on the same product collapses into one re-index instead of five. A Stripe-API job wrapped in RateLimited('stripe-api') respects the 100-requests-per-second cap without you writing a single counter.

Deep Dive

Retry Configuration

php
class ProcessPodcast implements ShouldQueue
{
    /**
     * Number of times to retry.
     */
    public int $tries = 3;

    /**
     * Maximum exceptions before failing.
     */
    public int $maxExceptions = 3;

    /**
     * Seconds to wait before retrying.
     */
    public int $backoff = 10;

    /**
     * Exponential backoff: 10s, 30s, 60s
     */
    public array $backoff = [10, 30, 60];
}

Time-Based Retries

php
class ProcessPodcast implements ShouldQueue
{
    /**
     * Retry until this time.
     */
    public function retryUntil(): DateTime
    {
        return now()->addHours(24);
    }
}

Timeout Configuration

php
class ProcessVideo implements ShouldQueue
{
    /**
     * Job timeout in seconds.
     */
    public int $timeout = 300;  // 5 minutes

    /**
     * Fail if timeout exceeded.
     */
    public bool $failOnTimeout = true;
}

Unique Jobs

Prevent duplicate jobs:

php
use Illuminate\Contracts\Queue\ShouldBeUnique;

class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    public function __construct(
        public Product $product
    ) {}

    /**
     * Unique identifier for the job.
     */
    public function uniqueId(): string
    {
        return $this->product->id;
    }

    /**
     * Seconds until uniqueness lock expires.
     */
    public int $uniqueFor = 3600;  // 1 hour
}

// Only one UpdateSearchIndex for product 123 can be queued
UpdateSearchIndex::dispatch($product);
UpdateSearchIndex::dispatch($product);  // Ignored (duplicate)

Unique Until Processing

php
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;

class GenerateReport implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
    // Allows another job to be queued once this one starts processing
}

Rate Limiting Jobs

php
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;

// In AppServiceProvider
RateLimiter::for('api-calls', function ($job) {
    return Limit::perMinute(60);
});

// In your job
class CallExternalApi implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('api-calls')];
    }
}

Preventing Overlapping Jobs

php
use Illuminate\Queue\Middleware\WithoutOverlapping;

class UpdateUserBalance implements ShouldQueue
{
    public function __construct(
        public User $user
    ) {}

    public function middleware(): array
    {
        return [
            new WithoutOverlapping($this->user->id),
        ];
    }
}

Release on Overlap

php
public function middleware(): array
{
    return [
        (new WithoutOverlapping($this->user->id))
            ->releaseAfter(60)  // Try again in 60 seconds
            ->expireAfter(180), // Lock expires in 3 minutes
    ];
}

Job Middleware

Apply middleware to jobs:

php
use Illuminate\Queue\Middleware\ThrottlesExceptions;

class ProcessWebhook implements ShouldQueue
{
    public function middleware(): array
    {
        return [
            new ThrottlesExceptions(10, 5),  // 10 exceptions per 5 minutes
        ];
    }
}

Custom Middleware

php
class LogJobMiddleware
{
    public function handle($job, $next)
    {
        Log::info('Starting job', ['class' => get_class($job)]);

        $result = $next($job);

        Log::info('Finished job', ['class' => get_class($job)]);

        return $result;
    }
}

// In your job
public function middleware(): array
{
    return [new LogJobMiddleware];
}

Common Pitfalls

  1. Setting $tries = 1 on a network-bound job — one blip and you're in the failed table. Use $tries >= 3 with a backoff array like [10, 30, 60] for anything that touches an external API.
  2. Forgetting uniqueId() on a ShouldBeUnique job — without an override, every dispatch has the same lock key, and only the first job in the entire app can run. Always implement uniqueId() to scope the lock to a specific entity.

Best Practices

  1. Tune $timeout to worst-case real runtime, not happy-path — a timeout shorter than real execution creates a retry loop that never succeeds. Benchmark the job under load before setting it.
  2. Prefer middleware over ad-hoc logic — WithoutOverlapping, RateLimited, and ThrottlesExceptions are battle-tested. Writing your own counter in handle() will have a race condition.

Summary

  • $tries, $backoff, $timeout are public properties on the job class.
  • ShouldBeUnique + uniqueId() deduplicates jobs by business key.
  • middleware() returns an array of queue middleware that wraps handle().
  • WithoutOverlapping, RateLimited, and ThrottlesExceptions cover most cross-cutting needs.
✓ Completed