<?php
namespace App\Http\Controllers;
use App\Jobs\SendNewsletterChunk;
use App\Models\NewsletterSend;
use App\Models\Subscriber;
use Illuminate\Support\Facades\Bus;
use Illuminate\Http\Request;
use Throwable;
class DispatchNewsletterController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'subject' => ['required', 'string', 'max:200'],
'body' => ['required', 'string'],
]);
$send = NewsletterSend::create($data + ['status' => 'queued']);
$jobs = Subscriber::subscribed()
->pluck('id')
->chunk(500)
->map(fn ($ids) => new SendNewsletterChunk($send->id, $ids->all()))
->all();
$batch = Bus::batch($jobs)
->name("newsletter:{$send->id}")
->allowFailures()
->then(fn () => $send->markCompleted())
->catch(fn ($b, Throwable $e) => report($e))
->finally(fn () => $send->refresh()->settle())
->onQueue('mail')
->dispatch();
$send->update(['batch_id' => $batch->id, 'status' => 'processing']);
return response()->json([
'id' => $send->id,
'batch_id' => $batch->id,
'progress' => $send->progress(),
], 202);
}
}
<?php
namespace App\Jobs;
use App\Models\NewsletterSend;
use App\Models\Subscriber;
use App\Mail\NewsletterMail;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
use Throwable;
class SendNewsletterChunk implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public int $sendId,
public array $subscriberIds
) {}
public function handle(): void
{
if ($this->batch()?->cancelled()) {
return;
}
$send = NewsletterSend::findOrFail($this->sendId);
$sent = 0;
$failed = 0;
$recipients = Subscriber::whereIn('id', $this->subscriberIds)
->where('subscribed', true)
->get(['id', 'email', 'name']);
foreach ($recipients as $subscriber) {
try {
Mail::to($subscriber->email)
->send(new NewsletterMail($send, $subscriber));
$sent++;
} catch (Throwable $e) {
report($e);
$failed++;
}
}
$send->increment('sent_count', $sent);
$send->increment('failed_count', $failed);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Bus;
class NewsletterSend extends Model
{
protected $fillable = [
'subject', 'body', 'status', 'batch_id', 'sent_count', 'failed_count',
];
protected $casts = [
'sent_count' => 'integer',
'failed_count' => 'integer',
'completed_at' => 'datetime',
];
public function progress(): int
{
if (! $this->batch_id) {
return $this->status === 'completed' ? 100 : 0;
}
$batch = Bus::findBatch($this->batch_id);
return $batch ? $batch->progress() : 100;
}
public function isFinished(): bool
{
$batch = $this->batch_id ? Bus::findBatch($this->batch_id) : null;
return $batch === null || $batch->finished();
}
public function markCompleted(): void
{
$this->update(['status' => 'completed', 'completed_at' => now()]);
}
public function settle(): void
{
if ($this->status !== 'completed') {
$this->update(['status' => 'partial', 'completed_at' => now()]);
}
}
}
Sending a newsletter to hundreds of thousands of subscribers in a single request is a recipe for timeouts and lost work. This snippet shows how to fan the send out into small, resumable chunks using Laravel's job batching, so a failure in one chunk never re-sends the whole list, and progress can be reported back to an admin UI in real time.
The DispatchNewsletterController is the entry point. Rather than looping over subscribers inline, it builds a batch of SendNewsletterChunk jobs — one per page of recipient IDs — using Subscriber::subscribed()->pluck('id')->chunk(500). Chunking by primary key (not by loading full models) keeps memory flat regardless of list size. The batch is created with Bus::batch(...), given ->allowFailures() so a single bad chunk doesn't cancel the run, and a ->then, ->catch, and ->finally callback set to mark the owning NewsletterSend record complete. The controller persists batch_id on the send so the frontend can poll it.
SendNewsletterChunk is the unit of work. It carries only the NewsletterSend id and an array of subscriber ids — small, serializable payloads that survive queue restarts. The InteractsWithBatch trait exposes $this->batch(); the guard if ($this->batch()->cancelled()) return; lets an admin abort a large send cleanly. Each recipient is wrapped in a try/catch so one malformed address increments a failure counter rather than throwing the whole chunk into the retry loop. Delivery counts are pushed back with an atomic increment, avoiding lost updates when many workers report concurrently.
The NewsletterSend model ties it together. progress() derives a percentage from Bus::findBatch($this->batch_id), reading the framework's own processedJobs() and totalJobs() so the number always matches reality even after retries. isFinished() and the sent_count / failed_count columns give the admin a durable audit trail after the batch record is pruned.
The key trade-off is granularity: a chunk size of 500 balances per-job overhead against blast radius on failure. Larger chunks mean fewer jobs but coarser retries; smaller chunks mean more queue traffic. Batching also requires a persistent batch store (the job_batches table), which is what makes progress queryable long after the individual jobs finish.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
Share this code
Here's the card — post it anywhere.