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
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
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
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
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
// 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
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.
.claude/skills/laravel-queues-and-jobs/SKILL.mdDrop 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