Laravel Magazine

Laravel Queue Tips for Reliable Background Jobs

Eric Van Johnson · Tips
Laravel Queue Tips for Reliable Background Jobs

Laravel queues are one of those features that feel almost too easy at first. You add implements ShouldQueue, dispatch a job, and you are done. Then production happens. Duplicate jobs pile up, rate-limited third-party APIs start returning 429s, and a failed job silently takes down an entire chain. Here are five techniques that make queue-heavy applications significantly more robust.

1. ShouldBeUnique — Stop Processing the Same Job Twice

Ever dispatch an ImportUserData job and have someone click the button twice before the first one finishes? Without protection, you get two concurrent imports. ShouldBeUnique prevents this by acquiring a lock before the job runs and releasing it when it finishes.

use Illuminate\Contracts\Queue\ShouldBeUnique;

class ImportUserData extends Job implements ShouldQueue, ShouldBeUnique
{
    public function __construct(public int $userId) {}

    // Lock key — jobs with the same userId won't overlap
    public function uniqueId(): string
    {
        return (string) $this->userId;
    }

    // How long to hold the lock (seconds)
    public int $uniqueFor = 3600;
}

The lock uses your cache driver, so Redis or Memcached work best in production. If a job with the same uniqueId is already queued or running, subsequent dispatches are silently discarded.

If you want uniqueness only while the job is queued (not while it is running), implement ShouldBeUniqueUntilProcessing instead. It releases the lock the moment the job starts executing, so a new dispatch can queue up immediately.

2. Job Batching — Track and React to Groups of Jobs

When you need to run a set of jobs and do something when they are all complete (or handle partial failures), Bus::batch() gives you that coordination without any manual bookkeeping.

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

$batch = Bus::batch([
    new ProcessPayment($orderId),
    new SendConfirmationEmail($orderId),
    new UpdateInventory($orderId),
])->then(function (Batch $batch) {
    // All jobs completed successfully
    Order::find($orderId)->markFulfilled();
})->catch(function (Batch $batch, Throwable $e) {
    // First job failure
    Order::find($orderId)->markFailed($e->getMessage());
})->finally(function (Batch $batch) {
    // Always runs, success or failure
    Cache::forget("processing:{$orderId}");
})->dispatch();

// Track progress later:
$batch = Bus::findBatch($batch->id);
echo $batch->progress(); // 0-100

Batches require a job_batches database table. Run php artisan queue:batches-table && php artisan migrate to set it up.

3. Rate Limiting Jobs — Respect Third-Party API Limits

When your jobs call an external API with a rate limit, the naive approach is catching 429 responses and retrying with backoff. The better approach is throttling at the dispatch level so you never send more requests than the API allows.

Laravel's RateLimiter facade integrates directly with job middleware:

use Illuminate\Queue\Middleware\RateLimited;

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

Define the limiter in AppServiceProvider::boot():

use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('slack-notifications', function ($job) {
    return Limit::perMinute(1)->by($job->teamId);
});

Jobs that hit the limit are automatically released back onto the queue with a calculated delay rather than failing. No 429s, no retry storms, no manual backoff logic.

4. Job Chaining — Sequential Jobs With Shared Context

Chains let you run jobs in sequence where each job only starts if the previous one succeeded. Unlike batches, chains share context implicitly through the application state rather than a batch object.

Bus::chain([
    new ValidateOrder($orderId),
    new ChargePayment($orderId),
    new SendReceipt($orderId),
    new NotifyWarehouse($orderId),
])->catch(function (Throwable $e) use ($orderId) {
    Order::find($orderId)->flagForReview($e->getMessage());
})->dispatch();

The chain stops at the first failure and calls the catch callback. Jobs after the failed one are never dispatched, which is exactly what you want for sequential workflows where each step depends on the previous one succeeding.

You can also catch errors per-job using withChainCatchCallbacks() if you need finer-grained control over which failures are recoverable.

5. $failOnTimeout and retryUntil() — Smarter Failure Handling

The default $tries property counts raw retry attempts, but this breaks down with exponential backoff because a job might exhaust its tries quickly even though the total elapsed time is short.

retryUntil() lets you set a deadline instead of a count. The job keeps retrying until the deadline passes:

class SyncExternalData implements ShouldQueue
{
    public function retryUntil(): DateTime
    {
        return now()->addHours(6);
    }

    public function backoff(): array
    {
        // Exponential backoff: 1min, 5min, 15min, 30min
        return [60, 300, 900, 1800];
    }
}

For jobs that should hard-fail if they take too long to execute, set $failOnTimeout:

public int $timeout = 120;
public bool $failOnTimeout = true;

Without $failOnTimeout = true, a timed-out job is released back to the queue and retried. With it, the job is marked as failed immediately, which is what you want for jobs where a timeout means something genuinely went wrong rather than a temporary slowdown.

Combined with retryUntil() and exponential backoff, this gives you a system that retries intelligently over time without running up against your $tries limit in the first few minutes.


These patterns are all built into Laravel's queue system, no third-party packages required. Pick the one that matches your biggest current headache, drop it in, and your ops team will have noticeably fewer 2am alerts.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.