Laravel queues are the difference between an application that merely works and one that stays responsive and reliable under real load. Dispatching a job is one line β the depth is in everything after: how workers pick jobs up, what happens when they fail, how you observe throughput, and how you coordinate thousands of jobs without losing track of any. This post covers the queue system the way you need to understand it to run it in production.
Designing jobs that survive production
A queued job is a class that gets serialized, stored, and executed later by a different process β possibly minutes later, possibly twice. Three design rules follow directly from that:

- Make jobs idempotent. Workers can die mid-job after the side effect but before the delete, and the job will run again. Charging a card, sending an email, or decrementing stock must therefore check whether the work already happened β a unique key on the operation, or a status check before acting.
- Pass identifiers, keep payloads small. Eloquent models in job properties are stored as class plus ID and re-fetched on execution (via the
SerializesModelstrait), which is what you want: fresh data, tiny payload. Avoid stuffing large arrays or file contents into properties; put files on disk or S3 and pass the path. - Expect deleted models. If the underlying row is gone when the job runs, model re-hydration throws. Set
public $deleteWhenMissingModels = trueon jobs where a vanished model simply means there is nothing to do.
Dispatching offers more control than most codebases use: dispatch($job)->onQueue('emails') routes to a named queue, ->delay(now()->addMinutes(5)) defers execution, and ShouldBeUnique prevents duplicate jobs from stacking up while one is already pending. For work that must only happen after the surrounding database transaction commits β which is almost always what you mean β dispatch with ->afterCommit() or set after_commit => true in the connection config, otherwise a fast worker can grab the job before the transaction that created its data has committed.
Retries, backoff, and timeouts
Failure handling is configuration you set per job, and the defaults are rarely right for every job class:
class SyncOrderToErp implements ShouldQueue
{
use Queueable;
public $tries = 5;
public $maxExceptions = 2;
public $timeout = 120;
public function backoff(): array
{
return [10, 60, 300, 900];
}
public function retryUntil(): \DateTime
{
return now()->addHours(6);
}
public function failed(?\Throwable $e): void
{
Notification::route('slack', config('services.ops.webhook'))
->notify(new ErpSyncFailed($this->order, $e));
}
}
Exponential backoff matters when the failure is a struggling downstream API β retrying instantly five times just extends the outage. retryUntil() caps total retry time regardless of attempt count, which suits time-sensitive work like payment webhooks. Set $timeout below your queue\'s retry_after value (the Redis/database drivers\' visibility timeout), or a slow job will be handed to a second worker while the first is still running it β the classic source of "why did this run twice" mysteries. When a job exhausts its attempts, it lands in the failed_jobs table and the failed() hook fires; php artisan queue:retry all re-dispatches after you fix the cause.
Horizon: supervision you can actually see
For Redis queues, Laravel Horizon replaces hand-written supervisor configs with code-defined worker pools and adds the dashboard the CLI never gave you: throughput, runtime percentiles, failed jobs with full exception traces, and per-queue wait times. Configuration lives in config/horizon.php:

'environments' => [
'production' => [
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['critical', 'default'],
'balance' => 'auto',
'minProcesses' => 2,
'maxProcesses' => 12,
'tries' => 3,
],
],
],
The auto balancing strategy shifts worker processes toward whichever queue has the deepest backlog, weighted by wait time β so a flood of cheap notification jobs cannot starve your critical queue. Two operational habits pay for themselves: run php artisan horizon:terminate in every deploy so workers restart on new code (workers hold the old code in memory until restarted β the most common "my fix didn\'t deploy" confusion), and wire wait-time alerts with waits thresholds so you hear about backlogs before customers do. One process supervisor entry keeps horizon itself alive; Horizon manages the rest.
Queue priority is architecture
Named queues are your priority system. A worker started with queue:work --queue=critical,default,low always drains critical before touching default. Split queues by latency requirement, not by feature: password-reset emails and payment confirmations are critical; weekly digests and search reindexing are low. Without this separation, one bulk import can queue 50,000 jobs in front of a password reset, and the user staring at their inbox does not care that your queue is technically working.
Batching: coordinating thousands of jobs
Job batching via the Bus facade solves the "process 10,000 rows, then tell me when everything is done" problem without hand-rolled counters:

$batch = Bus::batch(
$chunks->map(fn ($chunk) => new ImportContactsChunk($chunk))
)->before(fn (Batch $b) => Log::info("Batch {$b->id} created"))
->progress(fn (Batch $b) => Cache::put("import:{$b->id}", $b->progress()))
->then(fn (Batch $b) => event(new ImportCompleted($b->id)))
->catch(fn (Batch $b, \Throwable $e) => Log::error($e->getMessage()))
->finally(fn (Batch $b) => Cache::forget("import:{$b->id}"))
->allowFailures()
->dispatch();
The batch\'s progress is queryable by ID (Bus::findBatch()), which makes progress bars trivial. By default one failed job cancels the whole batch; allowFailures() flips that for imports where 3 bad rows should not abort 9,997 good ones. For sequential rather than parallel work β provision account, then configure, then notify β use a chain instead: Bus::chain([...])->dispatch() runs each job only after the previous one succeeds. Chains can live inside batches, which covers most real orchestration needs without a workflow engine.
Operational habits that prevent 2 a.m. pages
- Monitor queue depth and wait time, not just failures β a queue that is up but 40 minutes behind is an outage users can feel.
- Keep
failed_jobsnear zero. A table full of ignored failures trains the team to ignore the next real one. - Give workers a memory ceiling (
--max-jobs,--max-time, or Horizon\'smemoryoption) so gradual leaks recycle cleanly instead of OOMing. - Test job logic synchronously with
Queue::fake()assertions for dispatch behavior and directhandle()calls for business logic.
The queue system is one of Laravel\'s most complete subsystems β jobs, retries, Horizon, batches, and chains cover nearly every asynchronous pattern short of a distributed workflow engine. Learn the failure semantics deeply, because in production the interesting question is never "does the job run" but "what happens when it doesn\'t."
Related Service
π» Web Development
Custom websites and web applications built with PHP, Laravel, WordPress, and React β fast, secure, scalable, and tailored to your business goals.
Explore Web Development →
Reviews & Comments
Reviews are moderated and appear after approval.
No reviews yet β be the first to share your thoughts.
Leave a Review