Most Laravel applications start by doing everything inline: send the email right there in the controller, resize the image before returning the response, call the third-party API and wait. This works until one of those steps gets slow or flaky, and suddenly a single request is timing out because an email provider is having a bad day. Queues exist to separate "what the user needs right now" from "what can happen a moment later."
Why Background Work Needs a Queue
A queue turns a unit of work into a durable record. Instead of running code synchronously, you push a job onto a queue, a worker process picks it up, and if something goes wrong the job can be retried without the original request ever knowing. This matters for anything that talks to an external system: payment webhooks, transcoding, bulk notifications, report generation. The user gets a fast response, and the slow part happens off to the side.
Shaping a Job Class
A well-designed job declares its own retry behavior instead of relying on global defaults. The tries, backoff, and failed members give you fine control over how aggressively a job retries and what happens when it gives up.
<?php
namespace App\Jobs;
use App\Models\Podcast;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPodcastUpload implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $maxExceptions = 2;
public function __construct(public Podcast $podcast)
{
}
public function backoff(): array
{
return [10, 30, 60, 120, 300];
}
public function handle(): void
{
// Transcode audio, generate a waveform, notify subscribers.
}
public function failed(\Throwable $exception): void
{
$this->podcast->update(['status' => 'failed']);
}
}
The backoff() array means the fifth attempt waits five minutes rather than hammering a struggling API every few seconds. maxExceptions is separate from tries: it caps how many unhandled exceptions a job can throw before Laravel marks it failed outright, which is useful when a job releases itself back onto the queue intentionally (for example, waiting on a rate limit) rather than throwing.
retryUntil() on time-sensitive jobs instead of (or alongside) tries. A job that's still failing after fifteen minutes because an upstream API is down should stop retrying, not keep going until it exhausts a fixed attempt count.Running Workers in Production
In development, php artisan queue:work with no flags is fine. In production you want explicit limits so a worker doesn't accumulate memory or hold a stale deployment in memory forever.
php artisan queue:work redis \
--queue=podcasts,default \
--tries=5 \
--max-time=3600 \
--memory=256 \
--sleep=3
Listing queues in priority order (podcasts,default) means a worker always drains the higher-priority queue first. --max-time restarts the worker periodically, which pairs naturally with a process supervisor like Supervisor or systemd that will bring it straight back up with fresh code loaded.
Inspecting and Replaying Failures
Every job that exhausts its retries lands in the failed_jobs table with the exception and payload intact. This is your audit trail, not just a dead letter queue.
SELECT id, queue, exception, failed_at
FROM failed_jobs
WHERE queue = 'podcasts'
ORDER BY failed_at DESC
LIMIT 20;
Once you've fixed the underlying cause — a bad API key, a missing column, a third-party outage — you can replay failed jobs without rewriting any dispatch code:
php artisan queue:retry all
php artisan queue:retry 5,9,14
Both commands push the original job payload back onto its queue exactly as it was first dispatched, which is why keeping job constructors small and serializable (model IDs rather than loaded collections, for instance) matters so much.
A Few Habits Worth Keeping
- Keep job payloads small: pass model instances, not pre-loaded relationships, so Laravel re-fetches fresh data on each attempt.
- Make
handle()idempotent where possible, since a job can run twice if a worker is killed mid-execution before acknowledging the queue. - Use separate queues for latency-sensitive work (password resets) versus bulk work (nightly digests) so one doesn't starve the other.
Queues are one of the cheapest ways to make a Laravel application feel fast and resilient at the same time. The trick isn't dispatching the job — that's one line of code. It's deciding, up front, how that job behaves the second, third, and fifth time it fails.