Laravel Queues in Practice: Ship Background Jobs Without Freezing Your App (with Horizon)

Laravel Queues in Practice: Ship Background Jobs Without Freezing Your App (with Horizon)

Most web apps start synchronous: a controller handles a request, calls services, hits the database, returns a response. That’s fine—until you add “small” tasks like sending emails, generating PDFs, resizing images, syncing with third-party APIs, or processing imports. Suddenly your request times spike, users wait, and timeouts appear.

Laravel Queues let you push slow work to the background so your HTTP responses stay fast. In this hands-on guide, you’ll build a queue-backed workflow (emails + API sync), add retries/backoff, track failures, and monitor workers with Laravel Horizon.

What you’ll build

  • A SendWelcomeEmail job that runs asynchronously
  • A SyncCustomerToCrm job that calls an external API safely (retries + backoff)
  • Queue workers you can run locally and in production
  • Failure handling and monitoring with Horizon

1) Pick a queue driver (use Redis for real apps)

Laravel supports multiple queue backends (database, Redis, SQS, etc.). For production, Redis is the most common choice due to performance and Horizon integration.

Set your queue connection in .env:

QUEUE_CONNECTION=redis 

If you don’t already have Redis locally, you can run it with Docker:

docker run --name redis -p 6379:6379 -d redis:7-alpine 

Make sure Laravel can talk to Redis. Install the PHP Redis extension or use Predis. In most Laravel setups, it “just works” if Redis is available.

2) Create a job and dispatch it

Generate a job:

php artisan make:job SendWelcomeEmail 

Edit 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; public int $timeout = 20; // seconds public int $tries = 3; public function __construct(public int $userId) {} public function handle(): void { $user = User::findOrFail($this->userId); // Keep jobs resilient: fetch fresh data inside handle() Mail::to($user->email)->send(new WelcomeMail($user)); } } 

Now dispatch it after user registration (example controller snippet):

use App\Jobs\SendWelcomeEmail; public function store(Request $request) { $user = User::create([ 'name' => $request->string('name'), 'email' => $request->string('email'), 'password' => bcrypt($request->string('password')), ]); SendWelcomeEmail::dispatch($user->id); return response()->json(['id' => $user->id], 201); } 

Key idea: pass simple identifiers (like $user->id) instead of whole model objects. It avoids stale data and serialization surprises.

3) Run a worker (the job won’t run until you do)

Start a queue worker:

php artisan queue:work 

Now register a user and you should see the job process in the worker output.

Tip: For local development, you can use queue:listen, but queue:work is closer to production behavior and faster.

4) Build a “real-world” API sync job (retries + backoff + safe errors)

External APIs fail—timeouts, 500s, rate limits. Your job should retry safely, with backoff, and fail cleanly when it truly can’t recover.

Create the job:

php artisan make:job SyncCustomerToCrm 

Implementation:

<?php namespace App\Jobs; 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\Http; use Throwable; class SyncCustomerToCrm implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public int $timeout = 30; public int $tries = 5; public function __construct(public int $userId) {} // Backoff between attempts (seconds). Laravel supports arrays for progressive delay. public function backoff(): array { return [5, 15, 30, 60]; } public function handle(): void { $user = User::findOrFail($this->userId); $response = Http::timeout(10) ->retry(2, 200) // retry HTTP-level issues quickly before job-level retry kicks in ->post(config('services.crm.endpoint') . '/customers', [ 'external_id' => (string) $user->id, 'name' => $user->name, 'email' => $user->email, ]); if ($response->status() === 429) { // Rate limited — throw so the job retries with backoff throw new \RuntimeException('CRM rate limited (429).'); } if (!$response->successful()) { // Non-recoverable or unknown failure: throw to retry; if still failing, it ends in failed_jobs throw new \RuntimeException('CRM sync failed: ' . $response->status()); } } public function failed(Throwable $e): void { // Optional: notify, log, or mark a database flag // logger()->error('CRM sync permanently failed', ['user_id' => $this->userId, 'error' => $e->getMessage()]); } } 

Add a simple config entry (example):

// config/services.php return [ // ... 'crm' => [ 'endpoint' => env('CRM_ENDPOINT', 'https://example-crm.test/api'), ], ]; 

Dispatch it where it makes sense (e.g., after registration or profile update):

SyncCustomerToCrm::dispatch($user->id)->onQueue('integrations'); 

Using onQueue('integrations') lets you separate workloads (emails vs. integrations) and scale them independently.

5) Failed jobs: store them, inspect them, retry them

Set up failed job storage. Laravel supports a failed_jobs table:

php artisan queue:failed-table php artisan migrate 

If a job exhausts all retries, it lands in failed_jobs. Useful commands:

# list failed jobs php artisan queue:failed # retry a specific failed job ID php artisan queue:retry 5 # retry all failed jobs php artisan queue:retry all # forget a failed job php artisan queue:forget 5 

Practical rule: “Fail loudly, retry safely.” Don’t swallow exceptions in handle() unless you intentionally want the job to be considered successful.

6) Install and use Horizon (the dashboard you’ll actually use)

Horizon provides a real-time dashboard for Redis queues: throughput, runtimes, failures, and worker management.

composer require laravel/horizon php artisan horizon:install php artisan migrate 

Run Horizon locally:

php artisan horizon 

Visit /horizon in your app to see queues, jobs, and failures.

To configure queue balancing and worker counts, edit config/horizon.php. For example, a simple split between default and integrations:

'environments' => [ 'production' => [ 'supervisor-1' => [ 'connection' => 'redis', 'queue' => ['default', 'integrations'], 'balance' => 'auto', 'maxProcesses' => 10, 'tries' => 3, ], ], ], 

7) Battle-tested patterns for junior/mid devs

  • Pass IDs, not models: prefer new Job($user->id) and re-query in handle().

  • Use separate queues: default, emails, integrations, imports. Keep “slow/fragile” work isolated.

  • Keep jobs small and single-purpose: one job = one responsibility. Chain jobs when needed.

  • Set timeout and tries intentionally: don’t let jobs hang forever or retry endlessly.

  • Design idempotency: a job may run more than once. Ensure repeated execution doesn’t corrupt state (e.g., upsert in CRM, check a “synced_at” column).

  • Fail fast on bad input: findOrFail() is better than silently returning.

8) Quick test: prove a job is queued and executed

You can test job dispatching without running a worker using Laravel’s queue fakes:

use Tests\TestCase; use Illuminate\Support\Facades\Queue; use App\Jobs\SendWelcomeEmail; use App\Models\User; class RegistrationTest extends TestCase { public function test_it_dispatches_welcome_email_job(): void { Queue::fake(); $user = User::factory()->create(); SendWelcomeEmail::dispatch($user->id); Queue::assertPushed(SendWelcomeEmail::class, function ($job) use ($user) { return $job->userId === $user->id; }); } } 

This keeps your tests fast and avoids flaky background execution.

Wrap-up

Queues are one of the biggest “level-ups” in Laravel: they make your app feel fast while doing real work behind the scenes. Start simple (one Redis queue + one worker), then evolve toward multiple queues, retries/backoff, failed job tooling, and Horizon monitoring.

If you want a next hands-on article in the same style, good follow-ups are: job chaining + batching for imports, idempotent patterns with database locks, or deploying Horizon under Supervisor/systemd.


Leave a Reply

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