Laravel Queues in Practice: Background Jobs, Retries, and “Don’t Send That Email Twice”

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:

  • jobs table: pending jobs
  • failed_jobs table: 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:

  • ShouldQueue tells 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: $tries sets 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 $tries and $timeout intentionally 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/Mail in 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

Your email address will not be published. Required fields are marked *