Building a Weekly Digest Email Pipeline with the Scheduler and Queued Mail
Almost every app PHP Architect ships eventually needs a "here's what happened this week" email. Done naively — one big loop, one synchronous Mail::send() call per user — it either times out or hammers your mail provider's rate limits. Here's a version that does neither.
Step 1: The mailable
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Collection;
class WeeklyDigest extends Mailable
{
use Queueable;
public function __construct(
public User $user,
public Collection $activityItems,
) {}
public function build(): self
{
return $this->subject('Your week on PHP Architect')
->markdown('emails.weekly-digest', [
'user' => $this->user,
'items' => $this->activityItems,
]);
}
}
use Queueable on the mailable means calling ->queue() instead of ->send() will push it onto a queue rather than sending inline.
Step 2: A command that builds and dispatches digests
namespace App\Console\Commands;
use App\Mail\WeeklyDigest;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendWeeklyDigests extends Command
{
protected $signature = 'digest:send-weekly';
public function handle(): void
{
User::where('digest_opt_in', true)
->whereHas('activityItems', function ($query) {
$query->where('created_at', '>=', now()->subWeek());
})
->chunkById(200, function ($users) {
foreach ($users as $user) {
$items = $user->activityItems()
->where('created_at', '>=', now()->subWeek())
->latest()
->get();
Mail::to($user)->queue(new WeeklyDigest($user, $items));
}
});
}
}
chunkById() instead of get() keeps memory flat even with a large user base — it pages through results by primary key rather than loading everyone at once. whereHas() up front skips users who had zero activity, so you're not building and queuing empty digests.
Step 3: Rate-limit the actual sending
Queuing 50,000 emails at once will still get you rate-limited or blocked by most transactional mail providers if your queue workers fire them all in a burst. Put a rate limiter on the mail queue's job class:
namespace App\Providers;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('mail-sending', function () {
return Limit::perMinute(300);
});
}
}
namespace App\Mail;
use Illuminate\Queue\Middleware\RateLimited;
class WeeklyDigest extends Mailable
{
use Queueable;
public function middleware(): array
{
return [new RateLimited('mail-sending')];
}
}
Jobs (and queued mailables, which run through the same job middleware pipeline) that hit the limit are automatically released back onto the queue to retry shortly after, rather than failing outright.
Step 4: Schedule it
use Illuminate\Support\Facades\Schedule;
Schedule::command('digest:send-weekly')
->weeklyOn(1, '08:00')
->timezone('America/New_York')
->withoutOverlapping()
->onOneServer();
withoutOverlapping() prevents a second run from starting if the previous week's command is somehow still finishing up. onOneServer() matters the moment you run more than one app server with its own scheduler process — without it, every server would fire this command at 8am, and every user gets the digest multiple times.
Step 5: Make opting out trivial
public function build(): self
{
return $this->subject('Your week on PHP Architect')
->markdown('emails.weekly-digest', [
'user' => $this->user,
'items' => $this->activityItems,
'unsubscribeUrl' => URL::signedRoute('digest.unsubscribe', ['user' => $this->user]),
]);
}
A signed route means the unsubscribe link works without requiring the user to be logged in when they click it — which is exactly the situation you'll be in most of the time, since they're clicking from an email client, not an active session.
Put together: chunked queries so the command scales with your user base, queued mail so sending doesn't block, a rate limiter so your mail provider doesn't throttle you mid-run, and scheduler guards so you don't double-send. That's the whole pipeline, and none of it requires a third-party digest service.