TL;DR
- Shipped an email broadcast feature: audience filter → suppression gate → one queued job per recipient → per-recipient status ledger.
- The whole design rests on one idea: the recipient row is the source of truth, so retries and double dispatches are harmless.
- Delivery webhooks sync back onto that ledger with a rank guard so a late event never downgrades a status.
The shape of it
Sending one email is easy. Sending 5,000 and being able to answer "what happened to contact #4127?" is the actual problem.
Campaign (draft)
|
v
Audience filter -> Suppression gate -> CampaignRecipient rows (pending)
|
dispatch 1 job each
v
SendCampaignEmail
|
mail sent -> status: sent
|
webhook: delivered / opened / clicked / bounced
v
recipient ledger updated
One job per recipient, not one job per campaign. A 5,000-recipient blast becomes 5,000 small units of work — the queue throttles naturally, and a failure blast-radius is one email, not the whole campaign.
The suppression gate
Audience filtering and eligibility are two different questions, and mixing them is how you email someone who unsubscribed last week.
| Query | Includes | Used for |
|---|---|---|
baseQuery() |
matches the filter | preview counts in the UI |
eligibleQuery() |
filter + reachable | the actual send |
public function eligibleQuery(array $audience): Builder
{
return $this->baseQuery($audience)->emailReachable();
}
emailReachable() is a scope on the contact model — not do-not-contact, still subscribed, has an email. One scope, one place to change the rules, impossible to forget at the call site.
Idempotency lives in the row
The job takes IDs, not models, and every send re-checks the recipient's status before doing anything:
class SendCampaignEmail implements ShouldQueue
{
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public readonly int $recipientId,
public readonly int $tenantId,
) {}
// ...
}
If the queue redelivers, the status guard makes the second attempt a no-op. That's the whole trick — no distributed lock, no dedupe table, just a state machine on a row you already have.
The failed() hook matters just as much. Without it, a recipient stuck in pending after retries are exhausted leaves the campaign hanging in sending forever. Mark it failed, recompute stats, let the campaign finish.
Never downgrade a status
Delivery webhooks arrive out of order. A delivered event landing after opened should not walk the status backwards.
private const RANK = [
'sent' => 1, 'delivered' => 2, 'opened' => 3, 'clicked' => 4,
];
Monotonic progression, terminal off-ramps (bounced, unsubscribed, failed) handled separately. Two enums carry the vocabulary — one for the campaign lifecycle, one for the per-recipient state, both with label(), color() and description() so the UI never hardcodes a string.
Unsubscribe is a public route
The tokenised opt-out link has no auth, so it always renders the same generic confirmation — valid token or not. Otherwise you've built an oracle that tells an attacker which tokens exist.
The opt-out also back-fills any still-pending recipient rows to unsubscribed, so reports stay honest instead of counting a send that will never happen.
it('suppresses pending recipients when a contact unsubscribes', function () {
$recipient = pendingRecipient();
$recipient->contact->unsubscribeFromEmail('via link');
expect($recipient->fresh()->status)
->toBe(CampaignRecipientStatus::UNSUBSCRIBED);
});
Takeaway
Broadcast systems get complicated because people try to make the sending reliable. Make the ledger reliable instead, keep every job small and re-runnable, and reliability falls out for free.
Top comments (0)