Week 16: Queues, Events & Laravel Capstone

Every request so far has done all its work synchronously — the user waits for it to finish. This week moves slow work off the request cycle with queued jobs, adds events and notifications for things that happen "alongside" the main action, then closes out Laravel with a capstone tying together everything from Weeks 9 through 16.

Module 15 of 28 Week 16 of 32 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Move slow work off the request cycle with queued jobs & a worker process
  • Decouple side effects from business logic with events, listeners & notifications
  • Ship a complete, deployed Laravel application as the Module 2 capstone

1. Queues & Jobs

Some work is too slow to make a user wait for — sending an email, generating a report, calling a slow third-party API. A job represents that work as a class; dispatching it queues the work for later instead of running it inline:

terminal
php artisan make:job SendTaskDueSoonEmail
app/Jobs/SendTaskDueSoonEmail.php
<?php

namespace App\Jobs;

use App\Models\Task;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Mail;

class SendTaskDueSoonEmail implements ShouldQueue
{
    use Dispatchable, Queueable;

    public function __construct(public Task $task) {}

    public function handle(): void
    {
        Mail::to($this->task->user->email)->send(new \App\Mail\TaskDueSoon($this->task));
    }
}
dispatching it
<?php

use App\Jobs\SendTaskDueSoonEmail;

SendTaskDueSoonEmail::dispatch($task);
// Returns instantly -- the actual email send happens later, off the request

ShouldQueue is what makes dispatch() push the job onto a queue instead of running handle() immediately — the controller returns its response to the user right away, without waiting for the email to actually send.

2. Running a Worker

A queued job sits in storage (database, Redis, etc., configured in .env) until a separate worker process picks it up and runs it:

terminal
# .env
QUEUE_CONNECTION=database

php artisan queue:table   # generates the jobs table migration
php artisan migrate

php artisan queue:work    # runs continuously, processing jobs as they arrive

In production, queue:work runs as a supervised, long-lived process (via Supervisor or similar — covered in Week 28's deployment lesson), separate from the web server entirely. If a job throws an exception, Laravel retries it a configurable number of times, then moves it to a failed_jobs table for inspection rather than losing it silently.

3. Events & Listeners

An event announces that something happened; a listener reacts to it. This decouples "a task was completed" from "here's everything that should happen as a result" — new reactions can be added without touching the code that completes the task:

terminal
php artisan make:event TaskCompleted
php artisan make:listener SendCompletionNotification --event=TaskCompleted
app/Events/TaskCompleted.php
<?php

namespace App\Events;

use App\Models\Task;

class TaskCompleted
{
    public function __construct(public Task $task) {}
}
app/Listeners/SendCompletionNotification.php
<?php

namespace App\Listeners;

use App\Events\TaskCompleted;
use Illuminate\Contracts\Queue\ShouldQueue;

class SendCompletionNotification implements ShouldQueue // runs on the queue too
{
    public function handle(TaskCompleted $event): void
    {
        $event->task->user->notify(new \App\Notifications\TaskCompletedNotification($event->task));
    }
}
TaskController.php — dispatching the event
<?php

use App\Events\TaskCompleted;

public function update(UpdateTaskRequest $request, Task $task)
{
    $task->update($request->validated());

    if ($task->is_done) {
        TaskCompleted::dispatch($task);
    }

    return redirect()->route('tasks.index');
}

The controller only knows "a task was completed" — it has no idea what happens next, and doesn't need to. A second listener (a Slack notification, an analytics event) can be added later by registering it against the same TaskCompleted event, with zero changes to TaskController.

4. Notifications

A notification is a message deliverable through multiple channels (email, database, Slack, SMS) from one class:

terminal
php artisan make:notification TaskCompletedNotification
app/Notifications/TaskCompletedNotification.php
<?php

namespace App\Notifications;

use App\Models\Task;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;

class TaskCompletedNotification extends Notification
{
    public function __construct(public Task $task) {}

    public function via($notifiable): array
    {
        return ['mail', 'database']; // both an email and an in-app record
    }

    public function toMail($notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Task completed!')
            ->line("You finished: {$this->task->title}");
    }

    public function toArray($notifiable): array
    {
        return ['task_id' => $this->task->id, 'title' => $this->task->title];
    }
}

via() returning both 'mail' and 'database' means one ->notify() call sends an email and stores an in-app notification record, retrievable later via $user->notifications — a single declaration covering both delivery paths.

5. The Module Capstone

This week's exercise closes out Module 2. The target: a deployed, multi-feature task-tracker Laravel application, combining every piece from Weeks 9–16 into one coherent project.

capstone checklist
✓ Breeze authentication (Week 13)
✓ Tasks with tags, migrations & Eloquent relationships (Week 11)
✓ Eager-loaded, scoped queries -- no N+1 (Week 12)
✓ Policy-enforced ownership on every task action (Week 13)
✓ File attachments via the Storage facade (Week 14)
✓ A versioned, Sanctum-secured JSON API with API Resources (Week 15)
✓ A Pest test suite covering auth, CRUD & ownership (Week 15)
✓ A queued job + event/notification for due-soon tasks (this week)

6. Module Capstone Exercise

Module capstone

Finish the task tracker as a deployed, tested Laravel app

Everything from Weeks 9–16, working together in one application.

Requirements:

  1. Confirm every item on the capstone checklist above is implemented and working.
  2. Add a SendTaskDueSoonEmail job, dispatched from a new php artisan tasks:notify-due-soon Artisan command that finds tasks due within 24 hours and queues a notification for each.
  3. Add a TaskCompleted event, dispatched when a task's is_done flips to true, with a queued listener sending a TaskCompletedNotification via mail and database channels.
  4. Set up a real queue worker locally (QUEUE_CONNECTION=database, run php artisan queue:work) and confirm jobs actually process, including at least one deliberately-failed job visible in failed_jobs.
  5. Run the full Pest suite from Week 15 and confirm everything still passes after this week's additions.
Hint

Use Mail::fake() and Notification::fake() in Pest to assert a job or event actually queued the right mail/notification, without sending real email during test runs — Laravel's testing helpers exist specifically so async side effects stay verifiable.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

What does ShouldQueue change about how SendTaskDueSoonEmail::dispatch($task) behaves?

Without ShouldQueue, dispatching a job runs its handle() method synchronously, right there in the request — the user waits for it to finish. With ShouldQueue, dispatching only pushes the job onto a queue and returns immediately; a separate worker process picks it up and runs handle() independently, off the request entirely.

Q2

Why dispatch a TaskCompleted event from the controller instead of calling the notification code directly?

It decouples "a task was completed" from "everything that should happen as a result." The controller stays focused on its actual job (updating the task) and has no knowledge of — or dependency on — how many listeners react to the event. A second listener can be registered against the same event later with zero changes to the controller, which wouldn't be true if the notification call were hardcoded directly into update().

Q3

What happens to a queued job that fails every one of its configured retry attempts?

It's moved into the failed_jobs table rather than being silently discarded — the job's data and the exception that caused the failure are both recorded, so a developer can inspect what went wrong and, if appropriate, manually retry it later with php artisan queue:retry. This is what stops a queue failure from becoming an invisible data-loss bug.

Q4

Why does via() returning ['mail', 'database'] only require writing one ->notify() call at the point of use?

The notification class itself declares every delivery channel it should go through, along with a corresponding to*() method (toMail(), toArray() for the database channel) describing how the message looks on each one. Calling $user->notify(new TaskCompletedNotification($task)) triggers all of them together — the caller doesn't need to know or care how many channels are involved.