Laravel Queues in Practice: Background Jobs, Retries, and “Don’t Send That Email Twice”
When your Laravel app starts doing real work—sending emails, generating PDFs, resizing images, calling third-party APIs—you’ll hit a wall if everything runs during the HTTP request. Users wait, timeouts happen, and a single slow API call can drag down your whole app.
Queues solve that by moving heavy or unreliable tasks into background jobs. In this guide, you’ll build a queue-backed workflow that’s production-friendly: retries, timeouts, failed jobs, idempotency (avoiding duplicates), and a bit of testing.
1) Choose a Queue Driver (Database is Fine to Start)
Laravel supports multiple queue backends. For junior/mid devs, the database driver is the quickest to ship and debug.
In .env:
QUEUE_CONNECTION=database
Create the queue tables and migrate:
php artisan queue:table php artisan queue:failed-table php artisan migrate
That gives you:
jobstable: pending jobsfailed_jobstable: jobs that exhausted retries or crashed
2) Create Your First Job
Let’s send a “welcome email” and simulate an external dependency that can fail.
php artisan make:job SendWelcomeEmail
Example job (app/Jobs/SendWelcomeEmail.php):
<?php namespace App\Jobs; use App\Mail\WelcomeMail; use App\Models\User; 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; class SendWelcomeEmail implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; // Tune these per job: public int $tries = 5; // total attempts public int $timeout = 20; // seconds per attempt public int $backoff = 10; // seconds between attempts (simple case) public function __construct(public int $userId) { // Keep payload small: only scalar IDs, not full models. } public function handle(): void { $user = User::findOrFail($this->userId); // Idempotency: don't send twice if already sent. if ($user->welcome_email_sent_at) { return; } // Example "external call" behavior: // If your mail provider flakes, throw and let retries handle it. Mail::to($user->email)->send(new WelcomeMail($user)); $user->forceFill([ 'welcome_email_sent_at' => now(), ])->save(); } }
Notes:
ShouldQueuetells Laravel to queue it instead of running immediately.- Use IDs in the constructor. Serializing full models is heavier and riskier.
- Idempotency check prevents duplicate emails if the job is retried after a partial failure.
3) Dispatch the Job (Controller Example)
In your user registration flow (or wherever it fits):
use App\Jobs\SendWelcomeEmail; use App\Models\User; public function store(Request $request) { $user = User::create([ 'name' => $request->input('name'), 'email' => $request->input('email'), 'password' => bcrypt($request->input('password')), ]); SendWelcomeEmail::dispatch($user->id); return response()->json(['id' => $user->id], 201); }
Want to put it on a specific queue?
SendWelcomeEmail::dispatch($user->id)->onQueue('emails');
4) Run a Worker Locally
Start processing jobs:
php artisan queue:work
If you’re iterating quickly and want auto-reload on code changes:
php artisan queue:listen
queue:work is what you’ll use in production (faster, long-running). When you deploy new code, you restart the worker.
5) Handle Retries, Backoff, and Timeouts Like You Mean It
Jobs fail. The point is to fail safely and recover automatically.
- Retries:
$triessets how many times total to attempt. - Backoff: how long to wait before retrying.
- Timeout: kill a hung attempt, then retry.
You can make backoff smarter by returning an array (e.g., exponential-ish):
public function backoff(): array { return [5, 15, 30, 60, 120]; }
And you can choose when the job should stop retrying:
public function retryUntil(): \DateTime { return now()->addMinutes(10); }
Rule of thumb: use retries for transient failures (network, rate limits). Don’t retry “bad input” failures endlessly—validate up front.
6) When a Job Fails: Make It Visible and Recoverable
Once a job exhausts retries, it lands in failed_jobs.
See failed jobs:
php artisan queue:failed
Retry a failed job by ID:
php artisan queue:retry 12
Retry all:
php artisan queue:retry all
Delete a failed job:
php artisan queue:forget 12
For alerting, implement failed() on the job to log or notify:
use Throwable; use Illuminate\Support\Facades\Log; public function failed(Throwable $e): void { Log::error('Welcome email job failed', [ 'user_id' => $this->userId, 'error' => $e->getMessage(), ]); }
7) Avoid Duplicate Side Effects (Idempotency Patterns)
The nastiest bug with queues is doing the same side effect twice: sending two emails, charging twice, creating duplicate records. Retrying makes this more likely unless you design for it.
Common approaches:
- DB flag/timestamp (like
welcome_email_sent_at) - Unique constraints + “create or ignore” patterns
- Idempotency keys stored in a table (especially for payments)
Example migration for the email flag:
php artisan make:migration add_welcome_email_sent_at_to_users --table=users
public function up(): void { Schema::table('users', function (Blueprint $table) { $table->timestamp('welcome_email_sent_at')->nullable()->index(); }); }
This simple guard removes a whole class of “oops” incidents.
8) Run Workers in Production (Supervisor Example)
In production, you typically use supervisord (or systemd) to keep queue workers running and restart them if they crash.
Example Supervisor program config:
[program:laravel-queue] process_name=%(program_name)s_%(process_num)02d command=php /var/www/app/artisan queue:work --sleep=1 --tries=3 --timeout=30 autostart=true autorestart=true user=www-data numprocs=2 redirect_stderr=true stdout_logfile=/var/www/app/storage/logs/queue.log stopwaitsecs=3600
After deploying new code, restart workers so they pick it up:
php artisan queue:restart
queue:restart signals workers to gracefully exit after finishing the current job.
9) Testing Jobs Without Actually Running Them
You want confidence that your controller dispatches the right job, and that the job performs the right side effects—without sending real emails in tests.
Test dispatching:
use Illuminate\Support\Facades\Bus; use App\Jobs\SendWelcomeEmail; public function test_registration_dispatches_welcome_email_job(): void { Bus::fake(); $response = $this->postJson('/api/register', [ 'name' => 'Ava', 'email' => '[email protected]', 'password' => 'secret1234', ]); $response->assertCreated(); Bus::assertDispatched(SendWelcomeEmail::class, function ($job) { return is_int($job->userId); }); }
Test the job logic with Mail fake:
use Illuminate\Support\Facades\Mail; use App\Mail\WelcomeMail; use App\Jobs\SendWelcomeEmail; use App\Models\User; public function test_job_sends_email_only_once(): void { Mail::fake(); $user = User::factory()->create(['welcome_email_sent_at' => null]); (new SendWelcomeEmail($user->id))->handle(); (new SendWelcomeEmail($user->id))->handle(); Mail::assertSent(WelcomeMail::class, 1); }
10) A Quick Checklist for Real-World Queue Jobs
- Keep job payloads small (IDs, not huge arrays or full models).
- Set
$triesand$timeoutintentionally per job. - Make side effects idempotent (flags, unique constraints, idempotency keys).
- Log/notify on permanent failure via
failed(). - Run workers under a process manager; restart on deploy.
- Fake
Bus/Mailin tests to avoid real external calls.
Once you get comfortable with queues, your app feels faster, more resilient, and easier to scale. More importantly, you stop paying the “random timeout” tax that shows up the moment your app becomes popular.
Leave a Reply