[Field-Proven Complete Guide] Laravel Queue Design and Async Processing — Jobs/Queues/Horizon, Retries & Idempotency, Delays & Priorities, Failure Isolation, External API Integrations, User Notifications, and an Accessible Progress UI
What you’ll learn (key takeaways)
- How to decide what should be queued vs. what can remain synchronous
- Core Laravel Jobs/Queues architecture and how to choose Redis vs. DB drivers
- Retries (
tries/backoff), timeouts, and “dead-letter”-like operational patterns for isolating failures - How to prevent double execution with idempotency, and safely integrate external APIs
- How to use priority/queue splitting, delayed jobs, batches, and job chains in real projects
- Monitoring/alerts and worker operations in Horizon (restarts, scaling)
- An accessible UI for “waiting time” in async flows (
role="status",aria-live, non-color cues, retry paths)
Intended audience (who benefits?)
- Laravel beginner–intermediate engineers: want to queue emails/exports, but fear failures and double execution
- Tech leads / ops owners: want to avoid being late to job delays/failures and incident response
- PM/CS: want clear progress/completion notifications to reduce tickets and frustration
- Designers/QA/accessibility roles: want a consistent, “anyone-can-understand” system for waiting/completion/failure states
Accessibility level: ★★★★★
Async work inherently introduces “waiting,” so accessibility impact is large. This guide standardizes progress/result messaging with concrete patterns so flows can be completed via screen readers and keyboard navigation.
1. Introduction: Queues Aren’t Only About “Speed” — They’re About “Resilience”
In Laravel, queues aren’t just for making pages feel faster. The real value is decoupling heavy or unstable work from the request lifecycle and reshaping it into something that can retry, be isolated on failure, and be operated calmly.
Email delivery, PDF generation, CSV exports, external API calls, search indexing, and aggregation are often less stable when done synchronously: users wait, requests time out, and failure causes are harder to see. Solid queue design increases success rates, speeds up triage, and makes operations feel predictable.
2. When to Queue (If You’re Unsure, Start Here)
Consider queueing if any of the following apply:
- The task is heavy (seconds+, CPU/memory intensive)
- It depends on networks (external APIs, email) and fails intermittently
- It’s a bulk job (large exports, batch updates)
- It can finish later without breaking UX (tens of seconds to minutes)
- You want retry + failure isolation (a “recoverable” design)
What should remain synchronous:
- Minimum required writes for the page/action to complete (e.g., creating the order itself)
- Operations where users must instantly see the result (but extra work behind the scenes can still be queued)
A realistic pattern is: “core write is synchronous; notifications/aggregation/integrations are async.”
3. The Foundation: Driver Choice and Basic Setup
3.1 How to Think About Drivers
- Redis: fast and common; excellent with Horizon
- Database: easy to start; may not scale as smoothly under heavy load
- Cloud queues (SQS, etc.): ops simplicity, but needs careful design, cost, and observability planning
For small-to-mid SaaS, Redis + Horizon is often the most practical start: low barrier, and delays become visible quickly.
3.2 Basic Setup (Example)
.env:QUEUE_CONNECTION=redisconfig/queue.php: define queue names, retry intervals, failed-job retention policy- Prepare failed jobs store (
failed_jobs), depending on your chosen setup
4. Minimal Job Implementation: Split into Manageable Units
Keeping jobs close to “one job = one purpose” makes retries and failures easier to interpret.
// app/Jobs/SendWelcomeMail.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use App\Models\User;
use App\Mail\WelcomeMail;
class SendWelcomeMail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public int $userId) {}
public $tries = 5;
public $timeout = 60;
public function backoff(): array
{
return [10, 30, 60, 120, 300];
}
public function handle(): void
{
$user = User::findOrFail($this->userId);
Mail::to($user->email)->send(new WelcomeMail($user));
}
}
Notes
- Passing an
idis safer than passing the entire model (serialization and state-change resilience). - Making
tries/timeout/backoffexplicit often stabilizes operations overnight. - Don’t swallow exceptions—let them fail so monitoring can catch them.
5. Retry Design: Stronger When You Classify Failures
Not all failures are equal. In practice, splitting them into two types makes decisions faster:
- Transient failures (network wobble, temporary external API issues)
- retries often succeed
- Permanent failures (invalid input, deleted target, permission issues)
- retries are usually pointless; isolate for humans
5.1 Fail Fast for Permanent Failure Patterns
If an external API returns a 4xx, retries often won’t help. Branch by exception type/status and “give up early” rather than burning worker time.
5.2 Timeouts: The Practical Rule
- Too short → lower success rate
- Too long → workers clog and delays cascade
A safe starting point is ~2× your normal p95 for that job, then adjust based on observed delay.
6. Idempotency: Prevent Double Execution “By Design”
Queued jobs can run more than once due to:
- retries
- worker restarts
- re-enqueue after timeouts
- network flakiness
If you don’t design for this, you’ll see real incidents: “two emails,” “double charge,” “double points.”
6.1 Common Idempotency Patterns
- Assign a per-job
idempotency_key - Prevent concurrent/double execution via
Cache::lock()or DB unique constraints - If already completed, do nothing and exit
Example: prevent sending the same invoice email twice
public function handle(): void
{
$key = "invoice_mail:{$this->invoiceId}";
$lock = cache()->lock($key, 120);
if (!$lock->get()) {
return; // already running (or just ran)
}
try {
$invoice = Invoice::findOrFail($this->invoiceId);
if ($invoice->mail_sent_at) {
return; // already sent → no-op (idempotent)
}
Mail::to($invoice->user->email)->send(new InvoiceMail($invoice));
$invoice->forceFill(['mail_sent_at' => now()])->save();
} finally {
$lock->release();
}
}
Key points
- Persisting a “sent” flag in DB makes you resilient against retries and replays.
- Cache locks are great, but the final authority should be durable state (DB) when possible.
7. Queue Splitting and Priority: Stop Delay Cascades
With a single queue, a heavy job can block lightweight jobs. A field-proven approach is splitting queues by purpose:
high: close to user actions (notifications, lightweight integrations, urgent)default: normal worklow: heavy work (aggregation, search indexing, exports)
SendWelcomeMail::dispatch($user->id)->onQueue('high');
RecalcDailyUsage::dispatch($tenantId)->onQueue('low');
Run workers per queue to localize delays and make bottlenecks easier to see.
8. Delays, Chains, and Batches: When to Use Which
8.1 Delayed Jobs (delay)
Good for “retry later” or “follow-up notification later.”
SendFollowUpMail::dispatch($user->id)->delay(now()->addMinutes(10));
8.2 Chains (chain)
Good when order matters (generate → upload → notify).
Bus::chain([
new GenerateReport($reportId),
new UploadReport($reportId),
new NotifyReportReady($reportId),
])->dispatch();
8.3 Batches (batch)
Best for large job sets where you want overall progress and aggregated failure handling—especially exports and bulk updates.
9. External API Integration Pattern: Timeouts, Retries, and Fallbacks
External APIs fail. That’s exactly why they pair well with queues.
- define a short-ish
timeout - set retry count and spacing
- isolate failures + provide a manual recovery path
- use fallback when possible (cached value, retry later)
Laravel HTTP client works well inside jobs:
$res = \Illuminate\Support\Facades\Http::timeout(10)
->retry(3, 200)
->post($url, $payload);
if ($res->failed()) {
throw new \RuntimeException('external api failed');
}
“Throw on failure” becomes simpler in a job context because retries and isolation are the queue’s job.
10. Failed Job Operations: Isolate and Make It Visible
Queues are less about “zero failures” and more about “recoverable failures.” Key operational needs:
- notice growth in failed jobs (alerts)
- trace failure reasons (exception, job name, target IDs, trace ID)
- have a defined replay procedure (when/how to retry; when to fix data)
- turn permanent failures into product fixes or user messaging
Include at least these fields in failure logs:
- target IDs (
userId,orderId, etc.) - tenant ID (for multi-tenant)
trace_id(if request-originated)- external API response summary (mask sensitive info)
11. Monitoring with Horizon: Visibility Alone Buys Peace of Mind
With Horizon (Redis-based), you can see:
- throughput per queue
- failures
- wait time (delay)
- worker state
Operationally useful indicators:
queue_wait_time(rising means user impact risk)- failure rate spikes (external outages or deployment issues)
- job duration increases (early signal of clogging)
Start alerts small:
- sudden failure spike
- wait time above threshold
- worker down
That’s usually enough to keep operations manageable.
12. User Notifications and an Accessible Progress UI: Remove Async “Confusion”
From the user’s view, async often looks like “I clicked, nothing happened.”
Fixing this improves UX and reduces support volume.
12.1 Minimum 3 States
- Start: acknowledged
- In progress: processing (if needed)
- Done/Failed: result + next action
12.2 Standard UI Pattern (Start → Done Messaging)
@if(session('status'))
<div role="status" aria-live="polite" class="border p-3 mb-4">
{{ session('status') }}
</div>
@endif
@if(session('error'))
<div role="alert" class="border p-3 mb-4">
{{ session('error') }}
</div>
@endif
Start message example (export)
- “Export started. We’ll notify you when it’s complete.”
- If possible, add an expectation like “Usually completes within a few minutes,” but avoid over-promising.
12.3 Progress (Polling/Events): Practical Accessibility Rules
- show numeric progress in text (e.g., “40%”)
- use
aria-live="polite"and avoid overly frequent updates - don’t rely on spinner color alone
- keep keyboard paths for “Cancel” or “Back”
Example (concept):
<div id="progress" role="status" aria-live="polite">Preparing…</div>
12.4 Failure UX (Critical)
If the UI only says “Error,” the user is stuck. Provide:
- retry (button/link)
- alternatives (smaller file, narrower date range)
- a support/reference ID (
trace_id, etc.)
Those three reduce frustration dramatically.
13. Testing: Treat Queues as “Specifications” with Fakes
Instead of running queues in tests, stabilize behavior with Queue::fake() and assert dispatch.
use Illuminate\Support\Facades\Queue;
public function test_export_dispatches_job()
{
Queue::fake();
$user = User::factory()->create();
$this->actingAs($user);
$this->post('/export', ['range' => 'last_30_days'])
->assertRedirect()
->assertSessionHas('status');
Queue::assertPushed(\App\Jobs\ExportCsv::class);
}
For external APIs, use Http::fake() to model success/failure and prevent regressions in retry policy.
14. Common Pitfalls and How to Avoid Them
- Workers clog because jobs are too heavy
- Fix: split queues, split job granularity, adjust timeouts, “materialize” aggregation
- Double execution duplicates email/payments
- Fix: idempotency keys, “sent” flags, locks
- Failures are invisible until they pile up
- Fix: Horizon + alerts + structured logs
- External API instability causes retry storms
- Fix: cap retries, backoff, classify permanent failures, introduce circuit-breaker-like damping gradually
- Users don’t know what happened
- Fix: start/done/fail messaging,
role="status", retry paths
- Fix: start/done/fail messaging,
15. Checklist (Shareable)
Design
- [ ] Queued workloads are identified (heavy/unstable/delay-tolerant)
- [ ] Job granularity is appropriate (1 job ≈ 1 purpose)
- [ ] Idempotency exists (sent/completed checks)
- [ ] Queue splitting (
high/default/low) localizes delays
Operations
- [ ]
tries/backoff/timeoutare explicit - [ ] Failed jobs are visible (monitoring/alerts/logging)
- [ ] Replay procedure is documented (conditions, owner)
- [ ] External APIs have timeout/retry policy
UX/Accessibility
- [ ] Start/done/fail are explained in text
- [ ]
role="status"/aria-liveused where appropriate - [ ] Failure provides retry/alternatives/support ID
- [ ] Progress indicators don’t rely on color alone
Testing
- [ ]
Queue::fake()locks down dispatch behavior - [ ] External APIs use
Http::fake()for success/failure - [ ] Critical jobs test failure behavior (isolation/notifications)
16. Wrap-Up
Laravel queues are a powerful way to get both “speed” and “resilience” through async execution. The key is not relying on retries alone: prevent duplicates via idempotency, localize delay with queue splitting, and gain calm operations through Horizon and alerting. And because async introduces waiting, you must communicate clearly to users—start/done/fail states and accessible progress patterns that work with screen readers and keyboards. Start by queueing one workflow carefully—export, email, or an external API integration—and build from there.
References
- Laravel official docs
- Reliability & operations concepts
- Accessibility
