Why your queued jobs keep failing silently
Queued jobs fail differently than request-cycle code. There's no user staring at an error page, no request that times out and gets noticed — a job can fail, retry, fail again, and land in failed_jobs, and the first sign anything went wrong is a support ticket three days later asking where an email or a payment went.
ON THIS PAGE
What actually happens when a job throws
By default, when a queued job throws an exception, Laravel doesn't just discard it. It checks how many attempts the job has left — controlled by $tries on the job class, or the --tries flag on queue:work — and releases the job back onto the queue to try again if attempts remain. Once attempts are exhausted, the job is moved to the failed_jobs table and stops silently. No email, no Slack message, nothing — unless you've wired that up yourself.
That's the trap: the system is working exactly as designed, but "designed" here means "fail quietly into a database table" unless you configure otherwise.
Configuring retries and backoff
Set how many attempts a job gets, and how long to wait between them:
class ChargeCustomer implements ShouldQueue
{
public $tries = 5;
public function backoff(): array
{
return [10, 30, 60, 300]; // seconds between each retry
}
}
For jobs where retrying forever doesn't make sense past a certain point (a payment window that closes, a webhook that's no longer valid), retryUntil() is often a better fit than counting attempts:
public function retryUntil(): DateTime
{
return now()->addMinutes(30);
}
Handling the permanent failure
Once a job exhausts its retries, failed() is your last chance to do something about it — alert someone, roll back a partial side effect, mark a record as needing manual attention:
public function failed(Throwable $exception): void
{
Log::error('Charge failed permanently', [
'customer_id' => $this->customerId,
'exception' => $exception->getMessage(),
]);
Notification::route('slack', config('services.slack.alerts'))
->notify(new JobFailedNotification($this, $exception));
}
Without this, "permanently failed" and "silently disappeared" look identical from the outside.
The idempotency trap
Retries mean your job's handle() method might run more than once for the same logical unit of work — not just on exception, but if a worker is killed mid-job after the side effect already happened, or if someone manually runs queue:retry. If handle() charges a card or sends an email, "might run twice" is a real problem, not a theoretical one.
Two tools help here. ShouldBeUnique stops the same job from being queued twice while one is already pending or processing:
class ChargeCustomer implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return $this->customerId;
}
}
For the "already ran to completion, don't run again" case, that's on you — check for existing state before acting, not after:
public function handle(): void
{
if ($this->order->fresh()->charged_at) {
return; // already done, retry landed here for no reason
}
// ...charge the card, then set charged_at
}
Monitoring instead of discovering
php artisan queue:failed lists everything sitting in the failed jobs table. queue:retry {id} or queue:retry all re-runs them. queue:flush clears them out. These are fine for occasional manual checks, but they're not a monitoring strategy — nobody remembers to run queue:failed proactively.
If you're running Horizon, its dashboard gives you failed-job visibility for free. If not, a Queue::failing() listener in a service provider gets you a real-time alert instead of a table you have to remember to check:
Queue::failing(function (JobFailed $event) {
Notification::route('slack', config('services.slack.alerts'))
->notify(new JobFailedNotification($event->job, $event->exception));
});
One more gotcha: workers running stale code after a deploy
Queue workers are long-running processes — they load your application code once and keep running against that same code in memory. Deploy new code without restarting workers, and they'll keep executing the old version for however long they stay alive, sometimes for hours, silently.
The fix is one line in your deploy script:
php artisan queue:restart
This doesn't kill workers immediately — it signals them to finish their current job and exit, and your process manager (Supervisor, Horizon, systemd) restarts them with the new code.
Checklist before shipping a new job
- Does this job have an explicit
$triesandbackoff(), or are you relying on defaults you haven't actually checked? - Is there a
failed()method that does something — log, alert, cleanup — rather than nothing? - If this job has a side effect that shouldn't happen twice, does
handle()check for existing state before acting? - Is
queue:restartpart of your deploy process? - Do failures actually reach a human, or only a database table someone has to remember to query?