All topics
Laravel

6 min read

Queues & Jobs

A job must survive being run twice — retry backoff, failed-job visibility, and worker restarts as a real part of every deploy.

Principles

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.

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.

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.

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.

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

What goes wrong, what to do instead, and why the difference matters.

Non-idempotent job

Avoid

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,
        ]);
    }
}

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

Prefer

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]);
    }
}

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

No backoff on retries

Avoid

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

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

Prefer

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

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

Failures that vanish silently

Avoid

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.

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

Prefer

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

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

Review checklist

What I look for when reviewing a pull request that touches this area.

  • 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.

Take the skill

Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.

Download
.claude/skills/laravel-queues-and-jobs/SKILL.md

Drop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.

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