Queues are where an application's reliability assumptions get tested, because a queued job runs later, in a different process, possibly twice, possibly while something it depends on is broken.
Laravel makes dispatching one trivial. That is a good thing, and it leads reliably to the same three problems.
A job will run more than once
Start here, because everything else depends on it. A worker can be killed mid-job. A job can time out and be released. A deploy can restart the process. The guarantee is at least once, never exactly once.
So the question to ask about every job you write is what happens if this runs twice.
class CaptureLead implements ShouldQueue
{
public int $tries = 5;
public int $timeout = 30;
public function __construct(private readonly array $payload) {}
public function handle(LeadRepository $leads): void
{
// Converges on one row whether this is the first run or the fourth.
$leads->upsertBySourceRef(
source: $this->payload['source'],
sourceRef: $this->payload['id'],
attributes: $this->payload,
);
}
}That upsert behind a unique constraint is the thing making five attempts safe. Without it, five attempts are five rows, and the retry policy you configured for reliability has quietly become a data-corruption policy. Same ordering argument as in idempotent APIs and webhooks: idempotency first, retries second, never the reverse.
Retries belong on the job, not in a flag
A global --tries=3 treats an email and a payment identically, and they are not identical.
class NotifyPartner implements ShouldQueue
{
public int $tries = 8; // third-party API, worth persisting with
public int $maxExceptions = 3; // but not through 8 real errors
public function backoff(): array
{
return [10, 30, 60, 300, 900]; // seconds; last value repeats
}
public function retryUntil(): \DateTime
{
return now()->addHours(6); // after which the attempt is pointless
}
}Four knobs answering four different questions. tries caps attempts. maxExceptions caps how many of those attempts may actually throw, which diverges from tries because a job released while a lock was held has not thrown anything. backoff spaces the attempts so a struggling dependency is not hammered by its own retry storm. And retryUntil gives the job a wall-clock deadline, which matters for anything time-sensitive: a notification retried eight hours later is worse than one that failed.
The transaction trap
The most common Laravel queue bug, and it presents as a race condition because that is exactly what it is.
DB::transaction(function () use ($data) {
$lead = Lead::create($data);
CaptureLead::dispatch($lead->id); // worker may pick this up before commit
});The job hits the queue immediately. A worker claims it, queries for the id, and finds nothing, because the transaction has not committed. It fails with "No query results for model", you re-run it by hand, it works perfectly, and nobody can reproduce it.
Two fixes, and you want the second one:
CaptureLead::dispatch($lead->id)->afterCommit();// config/queue.php — make it the default for a connection
'redis' => [
// …
'after_commit' => true,
],Turn it on at the connection level and forget about it. The per-dispatch call is for the exceptions, not the rule.
failed_jobs is a monitor, not an archive
When attempts run out the job lands in failed_jobs with its payload and exception. Most teams discover this table for the first time during an incident, which is the wrong time to be learning its schema.
Give the job a failure path:
public function failed(\Throwable $e): void
{
Log::error('lead capture failed', [
'source_ref' => $this->payload['id'] ?? null,
'exception' => $e->getMessage(),
]);
// Something a human will actually see
Notification::route('slack', config('alerts.slack'))
->notify(new JobFailed(static::class, $this->payload));
}Then alert on the rate rather than the count. A handful of failures a day from a flaky third-party API is background noise. Fifty in ten minutes is an incident, and the difference between noticing now and noticing tomorrow is usually whether the payloads are still re-runnable.
queue:retry all exists and is extremely tempting at 2am. Read a few of the failures first, because retrying a thousand jobs that all fail for the same reason gets you a thousand identical failures and a load spike.
Three numbers
Queue depth is useful alongside the next one and misleading on its own, since a deep queue that drains is a busy system rather than a broken one.
Age of the oldest job is the real signal. A job that has been waiting twenty minutes means workers are stuck, dead, or simply outnumbered, and all three need you.
Failure rate, meaning growth in failed_jobs per minute rather than the total, which only ever goes up and so tells you nothing.
And one operational detail that causes more confusion than anything else on this page: workers load your code at boot and hold it. A deploy without queue:restart leaves workers cheerfully processing jobs with the previous release's code. Put it in the deploy script next to the migration step and never think about it again.
The three that matter
Laravel's queue is easy to adopt and easy to misconfigure in ways that only surface under load, which is to say in ways you find in production.
Make every job safe to run twice. Dispatch after commit. Set retries per job rather than globally.
Then watch the oldest job's age and the failure rate. Those two numbers tell you the queue is in trouble well before anyone files a ticket about an email that never arrived.