Queues

Laravel Intermediate/Advanced
Course 2 · Chapter 5 · Queues

⏳ Queues

Same architecture goal as Celery — hand slow work to a separate worker, return to the user immediately — but Laravel's queue system is built into the framework itself, no separate package required the way Django needs Celery bolted on. This chapter covers Job classes directly, going further than Chapter 4's brief ShouldQueue preview.

Queue Drivers

// .env
QUEUE_CONNECTION=database  // or redis, sqs, sync

database / redis

Real queue backends — jobs sit in a table or Redis list until a worker process picks them up, the same broker role Redis/RabbitMQ play for Celery.

sync: No Worker Needed

Runs jobs immediately, in the same process, no queue or worker at all — genuinely convenient for local development. Celery has an "eager mode" for similar behavior, but it's a less first-class, more manual configuration.

🛠️ Creating a Job

php artisan make:job GenerateBookReport
// app/Jobs/GenerateBookReport.php
namespace App\Jobs;

use App\Models\Book;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class GenerateBookReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Book $book  // the ACTUAL model — see below
    ) {}

    public function handle(): void
    {
        logger()->info("Report: {$this->book->title}, \${$this->book->price}");
    }
}

A Genuinely Better Answer to the Django Course's Gotcha

The Django project's Celery chapter had a whole gotcha dedicated to this: never pass a model instance to a task — pass its ID and re-fetch inside the task, because the instance gets serialized as a stale snapshot. Laravel's SerializesModels trait solves this automatically:

Celery's Manual Discipline vs Laravel's Automatic Handling

Celery (Django Course)
# Passing the model directly is a documented anti-pattern —
# the developer MUST remember to pass an ID instead:
@shared_task
def generate_report(book_id):  # NOT book
    book = Book.objects.get(pk=book_id)  # re-fetch by hand
Laravel: SerializesModels
public function __construct(public Book $book) {}
// The trait stores only the model's class + ID when queuing,
// then automatically re-fetches a FRESH instance from the database
// the moment the worker actually runs the job. Nothing to remember.

This is a genuine ergonomic win: the exact discipline the Django course had to teach as a rule to memorize is handled automatically by a trait every generated Job already includes.

Dispatching a Job

use App\Jobs\GenerateBookReport;

GenerateBookReport::dispatch($book);  // queues it, returns immediately

🔁 Retries and Failure Handling

class GenerateBookReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 10;  // seconds between retries

    public function __construct(public Book $book) {}

    public function handle(): void { /* ... */ }

    public function failed(\Throwable $exception): void
    {
        logger()->error("Report generation failed for book {$this->book->id}: {$exception->getMessage()}");
    }
}

Directly parallel to Celery's autoretry_for/retry_backoff/max_retries — same idea, class properties instead of decorator arguments.

Running the Queue Worker

php artisan queue:work
# Directly analogous to: celery -A mysite worker

💻 Coding Challenges

Challenge 1: Create and Dispatch a Job

Generate a ProcessOrder job that takes an Order model in its constructor and logs a summary line, then dispatch it from a controller after creating an order.

Goal: Practice the generate-job, dispatch workflow, passing the model directly.

→ Solution

Challenge 2: Add Retry Logic

Extend the ProcessOrder job with $tries = 3 and a failed() method that logs the failure, and explain what happens differently compared to a job with no retry configuration at all.

Goal: Practice configuring retries and handling permanent failure.

→ Solution

Challenge 3: Diagnose a Silently Stuck Queue

Given a controller that calls ProcessOrder::dispatch($order) but the order confirmation never seems to actually process, list two possible causes covered in this chapter and how to check for each.

Goal: Practice the same "queued but nothing consuming it" debugging instinct from the Django course's Celery chapter.

→ Solution

⚠️ Gotcha: A Missing Worker, or an Accidental sync Driver

Two distinct traps, both worth knowing: first, exactly like Celery, dispatch() queues a job silently even with zero workers running — if jobs never seem to execute, check that php artisan queue:work is actually running before assuming the job code is broken. Second, a Laravel-specific one: local .env commonly defaults QUEUE_CONNECTION to sync for easy local development (no worker needed) — it's easy to forget to switch this to database or redis before deploying, which means every "queued" job actually runs synchronously in production, silently defeating the entire point and blocking real requests on slow work.

🎯 What's Next

Background work is now handled correctly — the next chapter speeds up repeated work instead: Caching, Laravel's cache facade compared to Django's cache framework.