The fastest way to make an endpoint slow is to do real work inside it. Send an email, call a third-party API, generate a PDF, resize an image — each one adds its latency to a response the user is waiting on. The fix is almost always the same: get that work out of the request and onto a queue.
But "just queue it" hides the two questions that actually matter in production: what belongs on a queue, and what happens when the job fails halfway through.
What I move off the request
My rule is simple. If the work is not needed to produce the response, it does not belong in the request. The user placing an order needs to know the order was accepted — they do not need to wait while I send the confirmation email, notify the warehouse, and sync the invoice to accounting.
public function place(array $data): Order
{
$order = Order::create($data);
// The response can return now; everything below is queued.
SendOrderConfirmation::dispatch($order);
NotifyWarehouse::dispatch($order);
SyncInvoiceToAccounting::dispatch($order);
return $order;
}
The endpoint went from "as slow as the slowest third party" to "as slow as one insert." That is the whole point.
The rule nobody tells you: jobs run more than once
Here is the lesson that separates people who have run queues in production from people who have only configured them: a job can run twice. A worker dies mid-execution, the job times out and is retried, a deploy restarts the queue — and suddenly your "send one email" job has sent two, or your "charge the card" job has charged twice.
The defence is idempotency. The job must produce the same result whether it runs once or five times.
public function handle(): void
{
// A unique row is my idempotency key — the second run is a no-op.
$sent = NotificationLog::firstOrCreate(
['order_id' => $this->order->id, 'type' => 'confirmation'],
);
if ($sent->wasRecentlyCreated) {
Mail::to($this->order->email)->send(new OrderConfirmation($this->order));
}
}
Anything that touches money or sends a message to a human gets this treatment. A duplicate log line is annoying; a duplicate charge is a refund and an apology.
Retries, backoff, and failure that you can see
A job that can fail needs to say how it should be retried. I set explicit limits rather than relying on defaults, and I back off so a struggling third-party API gets room to recover instead of a retry storm.
public int $tries = 3;
public array $backoff = [10, 60, 300]; // seconds between attempts
public function failed(\Throwable $e): void
{
// After the last attempt, make the failure loud — not a silent dead letter.
Log::error('Invoice sync failed', ['order' => $this->order->id, 'error' => $e->getMessage()]);
}
The failed() method matters more than people think. A job that exhausts its retries and vanishes into the failed_jobs table without anyone noticing is how invoices quietly go unsent for a week. I want failures to page someone, or at least show up somewhere a human looks.
The bug that only appears under load
The nastiest queue bug I have chased was a job that worked every single time locally and failed at random in production. The cause was dispatching it from inside a database transaction:
DB::transaction(function () use ($data) {
$order = Order::create($data);
ProcessOrder::dispatch($order); // dispatched BEFORE the transaction commits
});
With a fast Redis worker, the job can start before the transaction commits — so Order::find($id) inside the job finds nothing and fails on a row that "does not exist yet." Locally the worker is slow enough that the transaction always commits first, so you never see it; under load it loses that race constantly. It is a true heisenbug.
The fix is one method — tell the dispatch to wait for the commit:
ProcessOrder::dispatch($order)->afterCommit();
I now make afterCommit() (or the queue connection's after_commit config) the default for anything dispatched inside a transaction. This is exactly the class of bug that never shows up in a tutorial and quietly costs you an afternoon the first time it bites.
What I run it on
For most projects this is Redis plus a couple of queue:work processes under a supervisor, with Laravel Horizon on top once there is more than one queue. Horizon earns its place the moment you want to see throughput, wait times, and failures without tailing logs — and the moment you want different priorities, so a password-reset email never sits behind ten thousand nightly report jobs.
The mental model I keep is this: the request is a promise that the work was accepted, and the queue is the machinery that makes sure it actually happens — exactly once, even when something goes wrong. Get those two ideas right and queues stop being a performance trick and become the backbone of a reliable backend.