DEV3 Technologies home

Throttled campaign batch sender

Split a mailing list into chunked queued jobs that respect a per-minute sending cap using Laravel's rate limiter and job batching.

LaravelQueue jobsemailbatchthrottlequeue

Code

<?php

namespace App\Actions;

use App\Jobs\SendChunk;
use App\Models\Campaign;
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

class SendCampaign
{
    public function __invoke(Campaign $campaign, int $chunkSize = 200): Batch
    {
        $jobs = [];

        // chunkById keeps memory flat even for very large audiences.
        $campaign->recipients()->select('id')->chunkById($chunkSize, function ($rows) use (&$jobs, $campaign) {
            $jobs[] = new SendChunk($campaign->id, $rows->first()->id, $rows->last()->id);
        });

        return Bus::batch($jobs)
            ->name("campaign-{$campaign->id}")
            ->allowFailures()
            ->finally(fn () => $campaign->update(['status' => 'sent', 'finished_at' => now()]))
            ->onQueue('mail')
            ->dispatch();
    }
}

How it works

What it solves

Mail providers and your own reputation both punish bursts. Sending 50,000 messages in a tight loop gets you throttled, or flagged. The fix is to cut the audience into chunks, queue them as a batch, and make each chunk wait for budget before it sends.

How it works

SendCampaign reads recipients with chunkById, so memory stays flat for large lists, and wraps each id range in a SendChunk job. All jobs go into one Bus::batch, which gives you a progress percentage, allowFailures() so one bad chunk does not cancel the rest, and a finally callback that marks the campaign done.

Throttling sits inside the job. RateLimiter::attempt with a key like send:<server> allows only a set number of sends per minute. When no budget remains the job calls release() with the seconds until the limiter frees up, which puts it back on the queue instead of sleeping a worker.

Each recipient is marked as sent with a conditional update before mailing, so a retried chunk never emails the same person twice.

Adapting it

Raise the cap gradually over several weeks for new sending domains. Change the key to include the tenant if every customer has their own limit. Use a Mailable with queue() disabled, because this job already runs in the queue.

Pitfalls

  • Run enough workers to drain a chunk within the minute.
  • Chunk size should be well below the per-minute cap.

How to use it

  1. Run php artisan make:queue-batches-table and migrate.
  2. Add a campaign_recipients table with campaign_id, contact_id and nullable sent_at.
  3. Copy SendCampaign and SendChunk.
  4. Start it: app(SendCampaign::class)($campaign).
  5. Set config('mail.cap_per_minute'), then run php artisan queue:work.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets