Queues are not optional
If your request handler calls somebody else’s API, you have already built a distributed system. The queue is how you stop their bad day from becoming your bad day.
Contents
There is a moment in most projects where someone adds a line to a controller that sends an email, or charges a card, or posts to a webhook. The request now depends on a machine in another country run by people you will never meet.
Nothing goes wrong for months. Then their API has a bad afternoon, every one of your web workers is sat waiting on a thirty-second timeout, and your site — which has nothing to do with them — is down.
The queue is not an optimisation. It is the boundary between your uptime and theirs.
What belongs on a queue
The rule I use: if it can fail independently of the request, it belongs on a queue.
- Email, SMS, push notifications
- Any call to a third-party API
- Image and video processing, PDF generation
- Search index updates
- Webhooks you send
- Reports, exports, bulk imports
What stays in the request: anything the user must see the result of before the page renders. That is a much shorter list than most controllers suggest.
The three properties every job needs
Getting a job onto a queue is easy. Getting it to survive contact with production comes down to three things, and the third one is where people get hurt.
It must be idempotent
A job will run twice. Not might — will. The worker gets killed after doing the work but before acknowledging it, a deploy restarts mid-job, a retry fires on a request that actually succeeded. If running twice charges the customer twice, you do not have a bug, you have a refund process.
Make the second run a no-op:
public function handle(): void
{
$charge = Charge::firstOrCreate(
['idempotency_key' => $this->key],
['order_id' => $this->order->id, 'status' => 'pending'],
);
if ($charge->status === 'succeeded') {
return; // already done, nothing to do
}
$result = $this->gateway->charge($charge->idempotency_key, $this->order->total);
$charge->update(['status' => 'succeeded', 'reference' => $result->id]);
}
Every payment provider supports an idempotency key. Use it. And prefer firstOrCreate with a unique index over “check then insert”, which has a race in it that will find you eventually.
It must be small
Pass ids, never objects with state:
// no: serialises the model, and it may be stale by the time it runs
dispatch(new SendInvoice($order));
// yes: Laravel's SerializesModels does this for you, but be deliberate
public function __construct(public int $orderId) {}
Laravel’s SerializesModels trait re-fetches the model when the job runs, which is what you want. A job holding a five-minute-old copy of a record is a data corruption bug waiting for the right Tuesday.
And keep jobs short. One job that does six things fails at step four and re-runs steps one to three. Six jobs chained together fail at four and resume at four.
Bus::chain([
new GenerateInvoicePdf($orderId),
new EmailInvoice($orderId),
new NotifyAccounting($orderId),
])->dispatch();
It must fail on purpose
The default of “retry forever” is how a broken job takes down a queue, and how a customer gets 400 identical emails.
class SyncToCrm implements ShouldQueue
{
public int $tries = 5;
public int $timeout = 30;
public array $backoff = [10, 60, 300, 900];
public function retryUntil(): \DateTime
{
return now()->addHours(4);
}
public function failed(\Throwable $e): void
{
Log::error('CRM sync failed permanently', [
'order' => $this->orderId,
'error' => $e->getMessage(),
]);
Notification::route('slack', config('alerts.ops'))
->notify(new JobFailed($this->orderId, $e));
}
}
Exponential backoff, a hard ceiling, and a failed() method that tells a human. A failed jobs table nobody reads is the same as no failed jobs table.
Operating them
Separate queues by urgency, not by feature. high for anything a user is waiting on, default for the rest, low for reports and backfills. One slow bulk export should not delay a password reset email.
dispatch(new SendPasswordReset($user))->onQueue('high');
Monitor depth, not throughput. A queue that is growing is the alert you want. Laravel Horizon gives you this out of the box on Redis, along with per-job runtimes, and it is worth the setup on any project big enough to have more than a handful of jobs.
Make deploys graceful. queue:restart signals workers to finish the current job and exit. Killing them mid-job is exactly the scenario your idempotency is for — but you should not be relying on it every single deploy.
Watch for the poison message. One malformed payload that throws instantly, retries, and burns your entire worker capacity. Rate limits and tries caps contain it.
The thing nobody tells you
Moving work to a queue does not remove the failure. It relocates it somewhere the user cannot see — which is a huge win, and also a trap, because now nobody notices when it breaks.
So the last piece is not technical. Somebody has to own the failed jobs table. An alert into a channel a human reads, checked with the same seriousness as an exception in the web app. Otherwise you will discover in three months that welcome emails stopped sending in June.
Ask me how I know.