Skip to content

20 — Campaign System

Table of Contents

  1. What Campaigns Are
  2. Campaign Model
  3. CampaignRecipient Model
  4. Campaign vs Notification — Key Differences
  5. CampaignsController
  6. Campaign Status Lifecycle
  7. SendCampaignJob
  8. Permissions Required
  9. Rate Limiting Considerations
  10. 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 failed status — the campaign sent status means the job completed, not that every message was delivered).
  • failed: The SendCampaignJob itself 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

public function __construct(public readonly int $campaignId) {}

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:

$delay = (int) Setting::get('campaign_send_delay_ms', 1500);
usleep($delay * 1000); // microseconds

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

  1. Click New Campaign.
  2. Enter an internal Title (not sent to customers).
  3. Select the Dealer whose customers will receive the message.
  4. Write the Message in the textarea. Available placeholders are shown as a quick reference below the field.
  5. 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