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();
}
}
<?php
namespace App\Jobs;
use App\Mail\CampaignMail;
use App\Models\Campaign;
use App\Models\CampaignRecipient;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
class SendChunk implements ShouldQueue
{
use Batchable, Queueable;
public int $tries = 0; // rate-limit releases must not count as failures
public int $maxExceptions = 3;
public function __construct(private int $campaignId, private int $fromId, private int $toId) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
$campaign = Campaign::findOrFail($this->campaignId);
$cap = (int) config('mail.cap_per_minute', 300);
CampaignRecipient::where('campaign_id', $campaign->id)
->whereBetween('id', [$this->fromId, $this->toId])
->whereNull('sent_at')
->with('contact')
->each(function (CampaignRecipient $recipient) use ($campaign, $cap) {
// No budget left this minute: put the job back and try later.
if (! RateLimiter::attempt('send:mail', $cap, fn () => true, 60)) {
$this->release(RateLimiter::availableIn('send:mail'));
return false;
}
// Claim first, so a retried chunk never mails the same person twice.
if (CampaignRecipient::whereKey($recipient->id)->whereNull('sent_at')->update(['sent_at' => now()])) {
Mail::to($recipient->contact->address)->send(new CampaignMail($campaign, $recipient));
}
});
}
}
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
- Run
php artisan make:queue-batches-tableand migrate. - Add a
campaign_recipientstable withcampaign_id,contact_idand nullablesent_at. - Copy
SendCampaignandSendChunk. - Start it:
app(SendCampaign::class)($campaign). - Set
config('mail.cap_per_minute'), then runphp 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.