Code review · PHP

Review a Laravel Queue Job

Medium Code review

Marketing wants a monthly newsletter. This Laravel job is dispatched once a month and runs on the default queue with --tries=3. There are 400,000 users.

Review it: list the problems, what would happen when it runs, and how to fix each one.

The code to review

There are 5 issues to find.

<?php

namespace App\Jobs;

use App\Mail\Newsletter;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class SendNewsletter implements ShouldQueue
{
    use Queueable;

    public function handle(): void
    {
        $users = User::all();

        foreach ($users as $user) {
            Mail::to($user->email)->send(new Newsletter($user));
        }
    }
}

Write your review

Loading the editor… (JavaScript is required to run code in your browser.)

The issues and how to fix them

Show the issues

1. Retries resend to everyone

Critical, line 17-20

If the job fails half-way (a timeout, an SMTP error), the queue retries it from the start: the first 200,000 users get the newsletter again, up to three times.

Fix: Fan out: one small job per user (or per chunk) so a retry only repeats that unit, and record who was sent the issue (a newsletter_deliveries row with a unique key) so a repeat is skipped.

2. Loads every user into memory

Major, line 17

User::all() hydrates 400,000 Eloquent models at once; the worker runs out of memory.

Fix: Iterate with User::query()->select('id', 'email')->chunkById(1000, ...) or lazyById().

3. One job runs far longer than the worker timeout

Major, line 15-21

Sending 400,000 emails synchronously takes hours. Laravel's default job timeout (60 s) and retry_after will kill the job and hand it to another worker, which starts again from the beginning while the first may still be running.

Fix: Dispatch per-chunk jobs (Bus::batch) that each finish in seconds, and set timeout and retry_after consistently.

Major, line 17

Every user is emailed, including those who unsubscribed or never opted in. That breaks anti-spam law (CAN-SPAM, GDPR/PECR) and damages the sender reputation of all email from the domain.

Fix: Filter on a consent flag, honour unsubscribes, and include a one-click unsubscribe link (List-Unsubscribe header).

5. Sends synchronously instead of using the mail queue and rate limits

Minor, line 20

send() inside a job is fine for one email, but bulk sending without rate limiting will hit the email provider's limits and fail repeatedly.

Fix: Queue each mailable (Mail::to(...)->queue(...)) on a dedicated mail queue and use RateLimited job middleware.

Queue job reviews are about failure modes: what happens on retry, on timeout and at full data size. The phrase interviewers want is "jobs must be idempotent", followed by how you would make this one so.

Fixed version

<?php

namespace App\Jobs;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Bus;

class SendNewsletter implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $issue)
    {
    }

    public function handle(): void
    {
        $jobs = [];
        User::query()
            ->where('newsletter_opt_in', true)
            ->select('id')
            ->chunkById(500, function ($users) use (&$jobs) {
                $jobs[] = new SendNewsletterChunk($this->issue, $users->pluck('id')->all());
            });

        Bus::batch($jobs)->onQueue('mail')->name("newsletter {$this->issue}")->dispatch();
    }
}

// SendNewsletterChunk::handle() skips users already recorded in newsletter_deliveries
// (unique on issue + user_id), queues each mailable, and records the delivery.

Follow-up questions

  • What is the difference between timeout and retry_after in Laravel queues?
  • How would you monitor that the batch finished? (Bus::batch callbacks, Horizon)