Laravel Queues in Practice: Background Jobs, Retries, and “It Won’t Double-Send” Safety
Queues are one of the simplest upgrades you can make to a Laravel app: they move slow work (emails, PDF generation, webhooks, image processing) out of the request/response cycle so your API stays fast and reliable.
This guide walks through a hands-on setup using Redis, then builds a real feature: “send a welcome email in the background” with retries, idempotency (no accidental duplicates), and a clean controller flow. Everything here works for junior/mid devs building production-ish apps.
1) Pick a Queue Driver (Redis is the usual choice)
Laravel supports multiple queue backends, but Redis is common because it’s fast and well-supported. You’ll typically run Redis locally via Docker or use a managed Redis in production.
Set your environment variables:
# .env QUEUE_CONNECTION=redis REDIS_HOST=127.0.0.1 REDIS_PORT=6379
In most Laravel versions, Redis is supported out of the box. If you’re using Docker locally, you can run Redis like this:
# docker-compose.yml (minimal) services: redis: image: redis:7 ports: - "6379:6379"
Start it:
docker compose up -d
2) Create a Job: SendWelcomeEmail
Let’s build a job that sends a welcome email to a user after signup. Generate a job:
php artisan make:job SendWelcomeEmail
Now implement it. The key points:
ShouldQueuemakes it asynchronous.$triesandbackoff()control retry behavior.timeoutprevents stuck jobs.- We’ll pass a
userId(not the whole model) to keep payloads small and avoid serialization surprises.
<?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; public int $tries = 5; public int $timeout = 15; public function __construct(public int $userId) { // optional: route this job to a dedicated queue $this->onQueue('emails'); } public function backoff(): array { // wait 10s, then 30s, then 2m, then 5m, then 10m return [10, 30, 120, 300, 600]; } public function handle(): void { $user = User::query()->findOrFail($this->userId); Mail::to($user->email)->send(new WelcomeMail($user)); } }
3) Create the Mail Class + View
Generate the mail:
php artisan make:mail WelcomeMail --markdown=mail.welcome
<?php namespace App\Mail; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class WelcomeMail extends Mailable { use Queueable, SerializesModels; public function __construct(public User $user) {} public function build(): self { return $this ->subject('Welcome to Acme App') ->markdown('mail.welcome', [ 'user' => $this->user, ]); } }
Example Markdown view:
<!-- resources/views/mail/welcome.blade.php --> @component('mail::message') # Welcome, {{ $user->name }} 🎉 Thanks for signing up. You can now log in and get started. @component('mail::button', ['url' => config('app.url')]) Open the app @endcomponent Thanks,<br> {{ config('app.name') }} @endcomponent
4) Dispatch the Job After Signup (and keep controllers fast)
Where you dispatch depends on your app architecture. A clean approach is: create the user, then dispatch the job.
Example controller method (simplified):
<?php namespace App\Http\Controllers; use App\Jobs\SendWelcomeEmail; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; class AuthController extends Controller { public function register(Request $request) { $data = $request->validate([ 'name' => ['required','string','max:100'], 'email' => ['required','email','max:255','unique:users,email'], 'password' => ['required','string','min:8'], ]); $user = User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); SendWelcomeEmail::dispatch($user->id); return response()->json([ 'id' => $user->id, 'email' => $user->email, ], 201); } }
Now your API returns immediately, while the worker sends email in the background.
5) Run a Worker Locally
Start the queue worker:
php artisan queue:work redis --queue=emails,default
--queue=emails,defaulttells the worker to listen to theemailsqueue first, then fallback todefault.- In production, you usually run workers under a process manager (Supervisor/systemd) or a platform feature (like Laravel Forge, Vapor, etc.).
6) Add Idempotency: Don’t Send the Welcome Email Twice
Retries can happen (network blip, mail provider hiccup). Also, bugs or double-clicks can dispatch the same job twice. If “welcome email” must be sent once per user, make that a rule in code.
One practical approach: track a flag on the user (or a separate table). Add a nullable timestamp:
php artisan make:migration add_welcome_emailed_at_to_users --table=users
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::table('users', function (Blueprint $table) { $table->timestamp('welcome_emailed_at')->nullable()->after('remember_token'); }); } public function down(): void { Schema::table('users', function (Blueprint $table) { $table->dropColumn('welcome_emailed_at'); }); } };
Now update the job to guard against duplicates safely. Use an atomic update inside a transaction-style pattern:
<?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\DB; use Illuminate\Support\Facades\Mail; class SendWelcomeEmail implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $tries = 5; public int $timeout = 15; public function __construct(public int $userId) { $this->onQueue('emails'); } public function backoff(): array { return [10, 30, 120, 300, 600]; } public function handle(): void { $user = User::query()->findOrFail($this->userId); // “claim” the send in a single DB write to prevent duplicates $updated = User::query() ->whereKey($user->id) ->whereNull('welcome_emailed_at') ->update(['welcome_emailed_at' => now()]); if ($updated === 0) { // Already sent (or currently being sent by another worker) return; } Mail::to($user->email)->send(new WelcomeMail($user)); } }
This pattern is simple and effective: if two workers race, only one “wins” the update.
7) Failed Jobs: Make Failures Visible (and Recoverable)
When jobs fail after all retries, you want them recorded. Enable failed job storage (driver depends on your Laravel version). Commonly:
php artisan queue:failed-table php artisan migrate
Now you can list failures:
php artisan queue:failed
Retry one:
php artisan queue:retry {id}
Or retry all:
php artisan queue:retry all
8) Testing the Flow (Fake the Queue and Mail)
In tests, you don’t want to actually send email or run workers. Laravel makes this easy with fakes.
<?php namespace Tests\Feature; use App\Jobs\SendWelcomeEmail; use Illuminate\Support\Facades\Queue; use Tests\TestCase; class RegisterTest extends TestCase { public function test_register_dispatches_welcome_job(): void { Queue::fake(); $res = $this->postJson('/api/register', [ 'name' => 'Jane', 'email' => '[email protected]', 'password' => 'secret1234', ]); $res->assertStatus(201); Queue::assertPushed(SendWelcomeEmail::class, function ($job) { return is_int($job->userId) && $job->userId > 0; }); } }
If you want to test that the mail is sent when the job runs, you can call the job’s handle() directly and fake Mail:
<?php use App\Jobs\SendWelcomeEmail; use App\Mail\WelcomeMail; use App\Models\User; use Illuminate\Support\Facades\Mail; Mail::fake(); $user = User::factory()->create(['welcome_emailed_at' => null]); $job = new SendWelcomeEmail($user->id); $job->handle(); Mail::assertSent(WelcomeMail::class, function ($m) use ($user) { return $m->hasTo($user->email); });
9) Practical Tips for Running Queues in Production
-
Use separate queues for different workloads. Email jobs shouldn’t be blocked behind a slow PDF job. Route jobs with
->onQueue('emails'),->onQueue('pdf'), etc. -
Set timeouts and retries intentionally. If a mail provider is down, retries help. But don’t retry forever.
-
Make important jobs idempotent. If “charge credit card” ever runs twice, it’s a bad day. Use DB flags, unique constraints, or “claim” updates like we did.
-
Monitor failures. Even if you don’t add a full dashboard, at least log failures and review them regularly.
Wrap-Up
You now have a real Laravel queue workflow: Redis-backed jobs, a worker, safe retries, protection against double-sends, failure visibility, and tests that don’t require running workers.
Once you’re comfortable with this, the next natural step is adding queue monitoring (like Horizon if your stack supports it) and expanding the same patterns to webhooks, report generation, and scheduled background tasks.
Leave a Reply