Laravel Queues and Jobs: A Practical Guide
By Pawan Singh · Jun 2026
Why Queue Work at All
Anything that doesn't need to finish before you respond to the user — sending email, generating a PDF, calling a slow third-party API, resizing an uploaded image — should run in the background. Queuing it keeps response times fast and, just as importantly, makes the operation retryable if it fails, instead of a failed email send taking down the whole request that triggered it.
Create a Job
php artisan make:job SendWelcomeEmail
class SendWelcomeEmail implements ShouldQueue
{
use Queueable;
public function __construct(public User $user) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeMail($this->user));
}
}
Anything passed into the constructor (here, $user) gets serialized when the job is queued and unserialized again when a worker picks it up — Laravel handles Eloquent model serialization specially, storing just the ID and re-fetching the fresh model when the job actually runs, rather than serializing the entire model state at dispatch time.
Dispatch It
SendWelcomeEmail::dispatch($user);
By default this runs synchronously unless you've configured a real queue driver — check QUEUE_CONNECTION in .env. Other useful dispatch variants: dispatch()->delay(now()->addMinutes(10)) to run later, or dispatch()->onQueue('emails') to route it to a named queue for separate worker scaling.
Choosing a Driver
- database — simplest to set up, fine for low-to-medium volume. Run
php artisan queue:table && php artisan migratefirst to create the jobs table. - redis — faster than the database driver, supports delayed jobs and rate limiting natively without extra querying overhead. The right default for anything beyond light production traffic.
- sqs — hand off durability and scaling to AWS if you're already in that ecosystem; good when you don't want to operate the queue infrastructure yourself.
Running the Worker
php artisan queue:work --tries=3
In production, run this under a process manager like Supervisor so it restarts automatically if it crashes, and restart it after every deploy (php artisan queue:restart) so workers pick up new code — a long-running worker process has your application's code loaded into memory from when it started, so a deploy without a restart runs old code until the worker eventually dies and Supervisor relaunches it.
Job Batching
When you need to run many related jobs and know when they've all finished — processing a bulk import row by row, for instance — use a batch instead of dispatching jobs individually with no coordination:
$batch = Bus::batch([
new ProcessRow($row1),
new ProcessRow($row2),
])->then(function (Batch $batch) {
// all jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
// first job failure detected
})->dispatch();
Batches also expose progress ($batch->processedJobs(), $batch->progress()), which is what a "78% complete" import progress bar is usually built on.
Job Middleware and Rate Limiting
Jobs can have their own middleware, most commonly used to rate-limit calls to a third-party API a job depends on:
public function middleware(): array
{
return [new RateLimited('third-party-api')];
}
This keeps rate-limit logic attached to the job itself rather than scattered across every place that might dispatch it.
Handling Failures
Failed jobs (after exhausting --tries) land in the failed_jobs table. Define a failed() method on the job to react — alert someone, log context, clean up partial state:
public function failed(Throwable $e): void
{
Log::error('Welcome email failed', ['user' => $this->user->id, 'error' => $e->getMessage()]);
}
Retry all failed jobs once the underlying issue is fixed with php artisan queue:retry all, or a specific one by its UUID. Use public $backoff = [10, 60, 300]; on the job to space out retries with increasing delay, rather than hammering a struggling downstream service three times in immediate succession.
Common Issues
- Jobs silently never run — the worker process isn't actually running (check with your process manager, not just assuming it survived a server reboot), or
QUEUE_CONNECTIONin.envis pointed at a different connection than what the worker is listening to. - Stale code after deploy — queue workers cache the booted application in memory; always run
php artisan queue:restartas part of every deploy script, not just when you remember something changed. - A job keeps failing with a stale/missing model error — the underlying record was deleted between when the job was dispatched and when a worker finally picked it up; guard against this in
handle()rather than assuming the model still exists. - Jobs "run twice" — usually a worker being killed mid-job without proper signal handling, so it's marked failed and retried even though the first attempt actually completed; make job logic idempotent (safe to run more than once) wherever practical, rather than relying on exactly-once delivery.
Need help designing a reliable background job pipeline in Laravel? Get in touch — I work as a freelance Laravel developer.