Queues and Jobs: Doing Slow Work in the Background
· 5 min read
Every lesson so far has assumed a request comes in, the server does some work, and a response goes back, all within the same single request-response cycle.
Every lesson so far has assumed a request comes in, the server does some work, and a response goes back, all within the same single request-response cycle. That assumption breaks down the moment part of the work is genuinely slow: sending an email through a third-party API, processing a large file, running a backtest across months of candle data, or calling an external service that might take several seconds to respond. Making the user's browser sit there waiting on all of that before they get a response is a bad experience and, worse, risks the whole request timing out. Queues and jobs are Laravel's answer: hand the slow work to a background process and respond to the user immediately.
What A Job Is
A job is a plain PHP class describing one unit of background work. You create one with php artisan make:job, and its entire contract is a single handle() method that does the actual work when the job eventually runs:
namespace App\Jobs;
use App\Models\Trade;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SendTradeConfirmationEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public Trade $trade) {}
public function handle(): void
{
// Imagine a slow call to an email provider's API here.
Mail::to($this->trade->user)->send(new TradeConfirmation($this->trade));
}
}
Dispatching A Job
Dispatching it from a controller is one line, and it is the only line in this entire lesson that actually matters for how your application behaves differently:
SendTradeConfirmationEmail::dispatch($trade);
return redirect()->route('trades.show', $trade);
dispatch() does not run handle() immediately. It serializes the job (including the $trade model, thanks to the SerializesModels trait, which stores just enough information, like the model's ID, to reload it fresh later rather than freezing a stale copy) and pushes it onto a queue, which by default is just a row in a jobs database table (configurable to use Redis or other drivers instead). The controller then immediately continues to the redirect() line, and the user gets their response right away, with the slow email-sending work happening separately, whenever a queue worker picks it up.
Running The Queue Worker
That queue worker is a separate, long-running PHP process, started with php artisan queue:work, that does nothing but continuously poll the queue for new jobs and run their handle() methods, one after another. This is a genuinely important operational detail, not just a code detail: if nothing is running queue:work, dispatched jobs simply sit in the jobs table forever, never executing, and the symptom from the user's side looks like "the confirmation email never arrived," with no error anywhere, because nothing actually failed; nothing ran at all. In production, this worker process needs to be kept alive permanently (commonly through a process supervisor), and critically, it needs to be restarted every time you deploy new code, because a running worker process has already loaded the old version of your job classes into memory and will keep using that old code until it is restarted, no matter how recently you deployed.
Handling Job Failures
Jobs can fail, and Laravel has a specific, deliberate mechanism for that rather than silently losing the work: a configurable number of automatic retries, and a failed() method you can define for cleanup or alerting if every retry is exhausted:
class SendTradeConfirmationEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public Trade $trade) {}
public function handle(): void
{
Mail::to($this->trade->user)->send(new TradeConfirmation($this->trade));
}
public function failed(\Throwable $exception): void
{
logger()->error("Failed to send trade confirmation for trade {$this->trade->id}: {$exception->getMessage()}");
}
}
$tries = 3 means the job gets up to three attempts total before Laravel gives up on it and moves it to a failed_jobs table (also just a database table, inspectable directly), calling failed() at that point. $backoff = 30 waits 30 seconds between attempts, which matters for a real-world case like a third-party API being briefly unavailable; retrying instantly, over and over, against a service that is down does nothing but add load, while a short wait gives it a real chance to recover.
A Common Queue Mistake
A mistake worth calling out specifically because it undoes the entire benefit of queueing in the first place: dispatching a job but then immediately needing its result inside the same request, for example dispatching a job to calculate something and then trying to display that calculated value on the very next line of the controller. The job has not run yet; it may not run for seconds, and under load, considerably longer. If a controller genuinely needs a result back before it can respond, that work either needs to happen synchronously (not as a queued job at all), or the architecture needs to change entirely, for example showing the user a "processing" state and polling or using a WebSocket/broadcast event to update them once the job actually finishes. Queueing is for work whose result the current request does not need to wait for; it is the wrong tool whenever the current request does need that result.
Try it yourself: create a job RecalculatePortfolioStats that sleeps for a few seconds (simulating slow work with sleep(5)), dispatch it from a controller action, and time how long the HTTP response actually takes to come back; then run php artisan queue:work in a terminal and watch it pick up and process the job separately, confirming the response time was unaffected by the simulated delay.
KEEP READING