20 — Campaign System¶
Table of Contents¶
- What Campaigns Are
- Campaign Model
- CampaignRecipient Model
- Campaign vs Notification — Key Differences
- CampaignsController
- Campaign Status Lifecycle
- SendCampaignJob
- Permissions Required
- Rate Limiting Considerations
- Campaign UI Walkthrough
What Campaigns Are¶
A Campaign in PyroRadius is a targeted bulk WhatsApp message send directed at all active customers belonging to a specific dealer. Campaigns allow dealers or admins to broadcast a custom message — promotions, maintenance notices, service updates — to their entire subscriber base in one action.
Campaigns differ from system-triggered notifications (payment received, account suspended) in that they are manually composed and dispatched by staff. They are not template-driven in the strict sense; while they may use the custom_message template key, the message body is entered freeform at campaign creation time.
Typical Use Cases¶
- Informing customers of scheduled downtime for fibre maintenance
- Announcing a Eid/special offer on package upgrades
- Reminding all subscribers of payment due dates at the start of a billing cycle
- Broadcasting a helpline number change
Campaign Model¶
Class: App\Models\Campaign
Table: campaigns
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
title |
varchar | Internal title for staff reference (not sent to customers) |
message |
text | The actual message body sent to customers |
status |
enum | draft, sending, sent, failed |
dealer_id |
bigint (FK) | The dealer whose customers receive this campaign |
sent_at |
timestamp nullable | When the send was initiated |
recipient_count |
integer | Total number of intended recipients at time of send |
created_by |
bigint (FK → users) | User who created the campaign |
created_at |
timestamp | — |
updated_at |
timestamp | — |
Relationships¶
public function dealer(): BelongsTo
{
return $this->belongsTo(Dealer::class);
}
public function recipients(): HasMany
{
return $this->hasMany(CampaignRecipient::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
Scopes¶
// Only campaigns in a sendable state
public function scopeDraft(Builder $query): Builder
{
return $query->where('status', 'draft');
}
// Campaigns sent in the last 30 days
public function scopeRecent(Builder $query): Builder
{
return $query->where('sent_at', '>=', now()->subDays(30));
}
CampaignRecipient Model¶
Class: App\Models\CampaignRecipient
Table: campaign_recipients
This is the pivot table that records each individual message delivery attempt within a campaign.
| Column | Type | Description |
|---|---|---|
id |
bigint (PK) | Auto-increment |
campaign_id |
bigint (FK) | Parent campaign |
customer_id |
bigint (FK) | Target customer |
phone |
varchar | Phone number at time of send (snapshot — customer may change phone later) |
status |
enum | pending, sent, failed |
sent_at |
timestamp nullable | When the WhatsApp message was dispatched |
error_message |
text nullable | Failure reason if status = failed |
created_at |
timestamp | — |
updated_at |
timestamp | — |
Why Snapshot the Phone Number¶
The phone column captures the customer's mobile number at the moment the campaign recipient record is created. This ensures the audit trail reflects the number actually dialled, even if the customer's phone number is later updated in the system.
Relationships¶
public function campaign(): BelongsTo
{
return $this->belongsTo(Campaign::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
Campaign vs Notification — Key Differences¶
| Aspect | Campaign | Notification |
|---|---|---|
| Channel | WhatsApp only | In-app + FCM push |
| Trigger | Manual (staff initiates) | Automatic (system event) OR manual |
| Target | All customers of a dealer (bulk) | All / a dealer / a specific customer |
| Message | Freeform text composed at creation | Structured (title + body) |
| Tracking | Per-recipient campaign_recipients row |
notification_reads (per user who viewed) |
| Retry | Per-recipient (only failed ones retried) | Not applicable (in-app always visible) |
| Template | Uses custom_message template for placeholders |
N/A |
CampaignsController¶
Class: App\Http\Controllers\CampaignsController
Route prefix: /admin/campaigns
Method Reference¶
index(Request $request): Response¶
Lists campaigns with filters. Super admins see all campaigns; dealers see only their own.
$campaigns = Campaign::query()
->when(auth()->user()->isDealer(), fn($q) => $q->where('dealer_id', auth()->user()->dealer_id))
->with(['dealer', 'createdBy'])
->withCount([
'recipients',
'recipients as sent_count' => fn($q) => $q->where('status', 'sent'),
'recipients as failed_count' => fn($q) => $q->where('status', 'failed'),
])
->orderByDesc('created_at')
->paginate(20);
create(): Response¶
Returns the campaign creation form. Passes the list of dealers (for admin) to populate the dealer selector.
store(Request $request): RedirectResponse¶
Validates and persists the campaign in draft status. Does not send yet.
$validated = $request->validate([
'title' => 'required|string|max:255',
'message' => 'required|string|max:4096',
'dealer_id' => 'required|exists:dealers,id',
]);
$campaign = Campaign::create([
...$validated,
'status' => 'draft',
'created_by' => auth()->id(),
]);
return redirect()->route('campaigns.show', $campaign);
show(Campaign $campaign): Response¶
Displays campaign details including recipient counts broken down by status. Also shows a preview of the message as it will appear (with sample placeholder values substituted).
send(Campaign $campaign): RedirectResponse¶
The core action. Transitions the campaign from draft to sending and dispatches SendCampaignJob.
public function send(Campaign $campaign): RedirectResponse
{
$this->authorize('campaigns.send');
if ($campaign->status !== 'draft') {
return back()->withErrors(['status' => 'Campaign has already been sent or is currently sending.']);
}
// Build recipient list from dealer's active customers
$customers = Customer::where('dealer_id', $campaign->dealer_id)
->whereNotNull('mobile')
->where('status', 'active')
->get();
// Create recipient records
foreach ($customers as $customer) {
CampaignRecipient::create([
'campaign_id' => $campaign->id,
'customer_id' => $customer->id,
'phone' => $customer->mobile,
'status' => 'pending',
]);
}
$campaign->update([
'status' => 'sending',
'sent_at' => now(),
'recipient_count' => $customers->count(),
]);
SendCampaignJob::dispatch($campaign->id)->onQueue('campaigns');
return redirect()->route('campaigns.show', $campaign)
->with('success', "Campaign dispatched to {$customers->count()} recipients.");
}
destroy(Campaign $campaign): RedirectResponse¶
Soft-deletes the campaign. Only draft campaigns can be deleted; sent campaigns are retained for audit.
Campaign Status Lifecycle¶
[draft] ──── send() called ────► [sending]
│
SendCampaignJob processes
each CampaignRecipient
│
┌────────────────┴────────────────┐
│ │
all sent or failed job fails
(normal completion) (unexpected error)
│ │
[sent] [failed]
- draft: Created but not yet dispatched. Can be edited or deleted.
- sending: Job is running. Recipient records are being updated as each message is processed.
- sent: All recipients have been processed (individual recipients may still have
failedstatus — the campaignsentstatus means the job completed, not that every message was delivered). - failed: The
SendCampaignJobitself encountered an unrecoverable error before finishing (e.g., WhatsApp service globally disabled mid-send).
Checking Completion¶
SendCampaignJob transitions the campaign to sent when it finishes processing the last recipient. It queries:
$allDone = CampaignRecipient::where('campaign_id', $campaign->id)
->whereIn('status', ['sent', 'failed'])
->count() === $campaign->recipient_count;
SendCampaignJob¶
Class: App\Jobs\SendCampaignJob
Queue: campaigns
Constructor¶
handle() Method Flow¶
1. Load Campaign with recipients (status = pending)
2. Check WhatsAppService::enabled() — if disabled, mark campaign 'failed' and exit
3. For each pending CampaignRecipient:
a. Render message via WhatsAppService::render('custom_message', $variables, $dealerId)
where $variables substitutes {customer_name} from customer record
b. Call WhatsAppService::send($recipient->phone, $renderedMessage, $dealerId)
c. On success: update recipient status = 'sent', sent_at = now()
d. On failure: update recipient status = 'failed', error_message = $error
e. Sleep 1–2 seconds between sends (rate limiting — see Rate Limiting section)
4. After all recipients processed:
a. Update Campaign status = 'sent'
b. Create a Notification record for the dealer informing them of campaign completion
Chunking¶
To avoid memory issues on large dealers with thousands of customers, recipients are processed in chunks of 50:
CampaignRecipient::where('campaign_id', $this->campaignId)
->where('status', 'pending')
->chunkById(50, function ($recipients) use ($campaign) {
foreach ($recipients as $recipient) {
$this->processRecipient($campaign, $recipient);
}
});
Retry Policy¶
public int $tries = 1; // The outer job does not retry
public int $timeout = 3600; // 1 hour timeout for large campaigns
Individual recipient failures are handled gracefully within the job — the job itself does not fail unless WhatsApp is globally disabled or an unhandled exception occurs.
Retrying Failed Recipients¶
Admins can retry failed recipients from the campaign detail page. This dispatches a new RetryCampaignRecipientsJob that re-processes only failed status recipients for the given campaign, without affecting already-sent ones.
Permissions Required¶
| Action | Permission |
|---|---|
| View campaign list | campaigns.view |
| Create a campaign | campaigns.create |
| Send a campaign | campaigns.send |
| Delete a draft campaign | campaigns.delete |
| View recipient details | campaigns.view |
| Retry failed recipients | campaigns.send |
These permissions are defined in the RBAC system and assigned per role. By default:
| Role | Permissions |
|---|---|
| Super Admin | All campaign permissions |
| Dealer Admin | campaigns.view, campaigns.create, campaigns.send (for own dealer only) |
| Engineer | campaigns.view only |
The CampaignsController enforces $this->authorize(...) on each method. Dealers are additionally scoped so they can only see and send campaigns associated with their own dealer_id.
Rate Limiting Considerations¶
WhatsApp Business accounts (and OpenWA sessions) are subject to anti-spam rate limits. Sending hundreds of messages too quickly can trigger a temporary ban on the WhatsApp number.
Current Rate Limiting Strategy¶
SendCampaignJob introduces a configurable sleep between each message send:
Default: 1,500 ms (1.5 seconds) between messages.
At this rate: - 100 recipients → ~2.5 minutes - 500 recipients → ~12.5 minutes - 1,000 recipients → ~25 minutes
Recommendations¶
| Recipient Count | Recommended Delay | Estimated Duration |
|---|---|---|
| < 100 | 1,000 ms | < 2 min |
| 100–500 | 1,500 ms | 2–13 min |
| 500–2,000 | 2,000 ms | 17–67 min |
| > 2,000 | 3,000 ms | Split across multiple days |
Adjusting the Delay¶
Navigate to Settings → Campaigns and update the campaign_send_delay_ms value. No code deployment required.
Session Rotation (Future Improvement)¶
If multiple ready WhatsApp sessions exist, a future enhancement could round-robin sends across sessions to increase throughput while staying within per-number rate limits. This is tracked as a pending enhancement.
Campaign UI Walkthrough¶
Route: GET /admin/campaigns
Page: resources/js/Pages/Campaigns/Index.jsx
Campaign List¶
Displays all campaigns in a table with columns: Title, Dealer, Status badge, Recipient Count, Sent/Failed breakdown, Sent At, Actions.
Status badges use colour coding:
- draft → grey
- sending → amber (pulsing)
- sent → green
- failed → red
Creating a Campaign¶
- Click New Campaign.
- Enter an internal Title (not sent to customers).
- Select the Dealer whose customers will receive the message.
- Write the Message in the textarea. Available placeholders are shown as a quick reference below the field.
- Click Save Draft. The campaign is saved but not yet sent.
Sending a Campaign¶
From the campaign detail page:
1. Review the message preview and the estimated recipient count.
2. Click Send Campaign.
3. A confirmation dialog shows: "This will send a WhatsApp message to X customers. Proceed?"
4. On confirmation, the send endpoint is called, SendCampaignJob is dispatched, and the page refreshes showing sending status.
5. The page auto-refreshes every 30 seconds while status = sending to show live progress.
Campaign Detail Page¶
Shows: - Campaign metadata (title, dealer, created by, sent at) - Message preview - Progress bar: sent / failed / pending of total recipients - Recipient table (paginated, filterable by status) - Retry Failed button (visible when there are failed recipients)
Last updated: 2026-07-13