A third-party endpoint was down for twenty minutes overnight. It is back, and now there are four hundred rows sitting in failed_jobs. The work is not lost, but it will not run again unless you push it back onto the queue. Here is how to see what you are dealing with and get it moving.
What a failed row actually holds
When a job runs out of attempts, Laravel writes a row containing the connection, the queue, the full original payload, the exception, and a failed_at timestamp. The payload is the important part: it is the same message that was on the queue, which is why a retry can recreate the job exactly rather than approximating it.
Start by looking at what you have:
php artisan queue:failed
That lists each failure with its UUID, connection, queue and class. For a real incident the list is usually too long to read, and the interesting question is which classes failed and why. SQL answers that faster:
select count(*), left(exception, 80)
from failed_jobs
group by left(exception, 80)
order by 1 desc;
Grouping by the first line of the exception separates one outage from the unrelated failures that happen to share the table. Retrying everything blindly is how you discover, too late, that half those rows were failing for a reason that has not gone away.
Retry one, some, or all
Once you know what you are looking at, queue:retry pushes the stored payload back onto its original queue:
# a single job
php artisan queue:retry 3f1e0e2a-6b25-4b6c-9d1d-2c9f1c5b0a11
# several at once
php artisan queue:retry 3f1e0e2a-... 9c22b7d4-...
# everything currently in the table
php artisan queue:retry all
You can also target a whole queue, which is usually what you want after an incident that only affected one integration:
php artisan queue:retry --queue=webhooks
A retried job is a fresh dispatch: its attempt counter starts again, and the row is removed from failed_jobs once it is queued. If it fails again, a new row appears.
Retry only what you actually fixed
There is no built-in filter by job class, which matters when one outage and one genuine bug are sitting in the same table. Pull the UUIDs you want and hand them over:
php artisan queue:retry $(
mysql -N -B app -e \
"select uuid from failed_jobs where payload like '%DeliverWebhook%'"
)
If you would rather stay inside the framework, the same idea works from Tinker:
DB::table('failed_jobs')
->where('payload', 'like', '%DeliverWebhook%')
->pluck('uuid')
->each(fn ($uuid) => Artisan::call('queue:retry', ['id' => [$uuid]]));
Before you retry four hundred jobs
Two things are worth checking first.
The job has to be safe to run again. Some of those failures happened after the work was partly done, so a retry repeats whatever came before the failure. That is exactly what making the job safe to run twice is for, and it is worth confirming before a bulk retry rather than after.
The dependency has to be genuinely healthy. Pushing four hundred jobs at a service that is still struggling will knock it over again and refill the table. If the jobs hammer one endpoint, retry a handful first and watch them.
Clearing what should not come back
Some failures are never going to succeed: a malformed payload, a record that has since been deleted, a feature you removed. Get them out of the way so the table stays a useful signal:
# drop a single job
php artisan queue:forget 3f1e0e2a-6b25-4b6c-9d1d-2c9f1c5b0a11
# empty the table
php artisan queue:flush
queue:flush also accepts --hours so you can clear only older entries and keep recent ones for review.
Keep the table from becoming noise
A failed_jobs table nobody looks at is the same as no failed jobs table. Two habits make it useful: prune old rows on a schedule, and alert when new ones appear rather than discovering them a week later. The failed() hook on the job itself is the natural place to hang that alert.
protected function schedule(Schedule $schedule): void
{
$schedule->command('queue:flush --hours=168')->weekly();
}
Wrapping up
Treat failed_jobs as a work queue of its own. Read it before you act on it, group by exception so you retry one incident rather than everything, confirm the jobs are safe to run twice, then push them back with queue:retry. Forget the ones that were never going to work, and prune on a schedule so the table still means something next time.
All comments ()
No comments yet
Be the first to leave a comment on this post.