---
name: laravel-queues-and-jobs
description: >-
  Laravel queues and jobs: idempotent job design, retry backoff, failed-job
  visibility, worker restarts on deploy, and realistic timeouts. Use when
  adding or reviewing a queued job.
argument-hint: "[job]"
license: MIT
metadata:
  author: John Felix Lim
  version: "1.0.0"
---
# Queues & Jobs

Apply when adding a queued job or reviewing one for reliability. Ask what happens if this exact job runs twice — if the answer is bad, that's the first thing to fix.

## Rules

1. **A job must be safe to run twice** — Queue workers retry on failure by default, and a redelivered message is a normal operating condition, not an edge case. A job that isn't idempotent will eventually double-charge, double-send, or double-create something.
2. **Retry with backoff, not immediately** — `$backoff` spacing retries out (10s, 30s, 60s) gives a transient failure — a flaky third-party API, a brief DB hiccup — room to resolve instead of hammering it in a tight loop.
3. **Failed jobs need a human-visible landing spot** — `failed_jobs` plus a notification on final failure turns 'silently stopped working' into 'someone finds out today,' not whenever a customer complains.
4. **Queue workers are a deployment concern, not just a code concern** — A deploy that doesn't restart workers runs old job code against a new schema, or vice versa. `php artisan queue:restart` after every deploy isn't optional.
5. **Long-running jobs need a realistic timeout, not the default** — A job with a 60-second default timeout processing a 10-minute video export gets killed mid-work every time. State `$timeout` deliberately per job, matching the actual work.

## Patterns

### Non-idempotent job

**Avoid** — A network blip that causes a retry — not even a real failure — results in the customer being charged twice.

```php
class ChargeCustomer implements ShouldQueue
{
    public function handle()
    {
        // A retried job charges the customer a second time.
        Stripe::charges()->create([
            'amount' => $this->amount,
            'customer' => $this->customerId,
        ]);
    }
}
```

**Prefer** — The exact same job running twice — retry, redelivery, whatever the cause — produces exactly one charge.

```php
class ChargeCustomer implements ShouldQueue
{
    public function handle()
    {
        // Idempotency key makes a retried request a no-op on Stripe's side.
        Stripe::charges()->create([
            'amount' => $this->amount,
            'customer' => $this->customerId,
        ], ['idempotency_key' => $this->chargeAttemptId]);
    }
}
```

### No backoff on retries

**Avoid** — A brief outage in the third-party API gets hammered with five immediate retries, which can make the outage worse.

```php
class SyncInventory implements ShouldQueue
{
    public $tries = 5; // retries immediately, five times in a row
}
```

**Prefer** — Retries space out, giving a transient failure realistic time to resolve before the job gives up.

```php
class SyncInventory implements ShouldQueue
{
    public $tries = 5;
    public $backoff = [10, 30, 60, 300]; // seconds between each retry
}
```

### Failures that vanish silently

**Avoid** — The only signal that something broke is a support ticket, long after the failure actually happened.

```php
// No failed() handler, no monitoring on the failed_jobs table.
// A job silently stops working and nobody knows until a
// customer reports missing data three weeks later.
```

**Prefer** — The team finds out the moment a job exhausts its retries, not whenever someone happens to check `failed_jobs`.

```php
class SyncInventory implements ShouldQueue
{
    public function failed(Throwable $exception): void
    {
        Notification::route('slack', config('services.slack.ops_webhook'))
            ->notify(new JobFailedNotification($this, $exception));
    }
}
```

## Review checklist

- [ ] Every job is safe to run twice — idempotency keys or existence checks guard against double-processing.
- [ ] Retries use `$backoff` spacing, not immediate consecutive attempts.
- [ ] Every job has a `failed()` handler or equivalent monitoring on `failed_jobs`, not silence on final failure.
- [ ] `$timeout` is set deliberately per job to match its realistic worst-case duration.
- [ ] `php artisan queue:restart` (or the deployment equivalent) runs on every deploy that touches job or model code.
- [ ] Jobs that must run in order use a single queue/worker for that chain, not parallel workers racing each other.

---

From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim