Search by

lindemannrock / craft-campaign-manager

bhlindemann

Campaign management for Craft CMS surveys with SMS and email invitations, recipient tracking, and analytics.

Package info

github.com/LindemannRock/craft-campaign-manager

Documentation

Type:craft-plugin

pkg:composer/lindemannrock/craft-campaign-manager

Statistics

Installs: 61

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

5.16.1 2026-10-18 08:02 UTC

README

Latest Version Craft CMS PHP Formie License

Campaign management for surveys with SMS and email invitations for Craft CMS 5.x.

License

This is a commercial plugin licensed under the Craft License. It will be available on the Craft Plugin Store soon. See LICENSE.md for details.

⚠️ Pre-Release

This plugin is in active development and not yet available on the Craft Plugin Store. Features and APIs may change before the initial public release.

Features

  • Campaign Management: Create and manage survey campaigns linked to Formie forms
    • Multi-site support with site-specific recipients
    • Campaign types for organization
    • Configurable invitation delay and expiry periods
  • Recipient Management:
    • Import recipients from CSV files
    • Add individual recipients manually
    • Track invitation status (sent, opened, submitted)
    • Unique invitation codes per recipient
    • Export recipients to CSV/JSON/Excel
  • Multi-Channel Invitations:
    • SMS invitations via SMS Manager
    • Email invitations with customizable templates
    • Bitly URL shortening for SMS links
    • Provider transport holds preserve pending SMS recipients while email continues independently
  • Queue-Based Processing:
    • Batch processing for large recipient lists
    • Background job execution
    • Progress tracking
  • Analytics Dashboard:
    • Overview stats (recipients, invitations, opens, submissions)
    • Daily activity charts
    • Channel distribution (Email/SMS/Both)
    • Engagement tracking over time
    • Conversion funnel visualization
    • Campaign performance comparison
    • Ratings tab (when formie-rating-field is enabled): per-campaign NPS / Star / Emoji rating analytics with rating field picker, distribution chart (donut for NPS, bar for star/emoji), trend chart, and per-campaign breakdown table
    • Multi-section export: Excel (multi-sheet: Summary / Per Campaign / Raw Responses), CSV (ZIP of per-section CSVs), JSON (nested with all sections); Raw Responses includes per-recipient detail (campaign, site, send/response dates, rating value)
    • Filter by campaign, site, and date range; Ratings tab also supports a "Date based on" filter (Send activity keeps recipients invited in the range, Response date keeps forms submitted in the range)
    • Optional Craft dashboard widgets for analytics summary and campaign performance, with date-range and site filters
  • Survey Response Tracking:
    • Link Formie submissions to recipients
    • Track survey completion rates
    • Invitation expiry handling
    • View responses directly in campaign edit page
  • User Permissions: Granular access control for campaigns, recipients, analytics, and settings
  • Logging: Structured logging via Logging Library with configurable levels

Requirements

  • Craft CMS 5.11 or greater
  • PHP 8.2 or greater
  • Formie 3.0 or greater
  • SMS Manager 5.18.1 or greater (required by Composer; install and enable in Craft for SMS delivery and shared provider transport protection)
  • Logging Library 5.0 or greater (installed automatically)
  • Plugin Base 5.39.0 or greater (installed automatically)

Installation

Via Composer (Development)

Until published on Packagist, install directly from the repository:

cd /path/to/project
composer config repositories.campaign-manager vcs https://github.com/LindemannRock/craft-campaign-manager
composer require lindemannrock/craft-campaign-manager:dev-main
./craft plugin/install campaign-manager

Via Composer (Production - Coming Soon)

Once published on Packagist:

cd /path/to/project
composer require lindemannrock/craft-campaign-manager
./craft plugin/install campaign-manager

Via Plugin Store (Future)

  1. Go to the Plugin Store in your Craft control panel
  2. Search for "Campaign Manager"
  3. Click "Install"

Configuration

Settings

Navigate to Campaign Manager → Settings in the control panel to configure:

General Settings:

  • Plugin Name: Customize the display name in the control panel
  • Campaign Section Handle: The section handle where campaigns are stored
  • Default Sender ID: The SMS Manager sender that campaigns without a sender of their own send through. Leave it on Use SMS Manager default to inherit whatever SMS Manager has configured as its default sender; the option names that sender while one resolves and reads none configured when no sender currently resolves, for example because SMS Manager has no default or its default sender is disabled. Pick a specific sender to pin the plugin default regardless of later SMS Manager changes.

Bitly Settings:

  • Bitly API Key: API key for URL shortening (environment variable recommended)

Logging Settings:

  • Log Level: debug, info, warning, error

Environment Variables

# .env
BITLY_API_KEY=your-bitly-api-key

Config File

Create a config/campaign-manager.php file to override default settings:

<?php
return [
    // Plugin Settings
    'pluginName' => 'Campaign Manager',
    'campaignSectionHandle' => 'campaigns',

    // Logging Settings
    'logLevel' => 'error',

    // Multi-environment support
    'dev' => [
        'logLevel' => 'debug',
    ],
    'production' => [
        'logLevel' => 'error',
    ],
];

Setup

1. Create Campaign Section

Create a Craft section for campaigns with the following fields:

  • Campaign Type (Dropdown): Type categorization
  • Form (Formie Form): The survey form
  • Invitation Delay Period (Text): ISO 8601 duration (e.g., P1D for 1 day)
  • Invitation Expiry Period (Text): ISO 8601 duration (e.g., P30D for 30 days)
  • SMS Invitation Message (Plain Text): SMS template with {invitationUrl} and {customer_name} tokens
  • Email Invitation Subject (Plain Text): Email subject line
  • Email Invitation Message (Rich Text): Email template with tokens
  • Sender ID (Dropdown): The SMS Manager sender this campaign sends through. Leave it on "Use Campaign Manager default" to inherit the plugin's Default Sender ID setting; when that setting is empty, SMS Manager's default sender is used.
  • Surveys Welcome (Rich Text): Message shown before survey
  • Surveys Already Responded (Rich Text): Message for completed surveys
  • Surveys Invitation Expired (Rich Text): Message for expired invitations

2. Configure Plugin Settings

  1. Navigate to Campaign Manager → Settings
  2. Set the Campaign Section Handle to match your section
  3. Configure Bitly API key if using SMS invitations

3. Create Survey Template

Create a template for the survey page (e.g., templates/survey.twig):

{% extends '_layouts/surveys.twig' %}

{% block content %}
    {% set invitationCode = craft.app.request.getQueryParam('code') %}

    {% if invitationCode %}
        {% set recipient = campaignManager.recipients.getRecipientByInvitationCode(invitationCode) %}
        {% set campaign = recipient.getCampaign() %}

        {% if recipient.hasSubmission() %}
            {{ campaign.surveysAlreadyResponded|raw }}
        {% elseif recipient.invitationIsExpired() %}
            {{ campaign.surveysInvitationExpired|raw }}
        {% else %}
            {{ campaign.surveysWelcome|raw }}
            {{ craft.formie.renderForm(campaign.getForm()) }}
        {% endif %}
    {% else %}
        <p>Invalid invitation code.</p>
    {% endif %}
{% endblock %}

Usage

Managing Campaigns

  1. Navigate to Campaign Manager in the control panel
  2. Click New Campaign to create a campaign entry
  3. Configure the campaign settings and associated form
  4. Save the campaign

Adding Recipients

Single Recipient

  1. Navigate to the campaign's recipient list
  2. Click Add → New Recipient
  3. Enter recipient details (name, email, phone, site)
  4. Save

Import from CSV

  1. Navigate to the campaign's recipient list
  2. Click Add → Import from CSV
  3. Upload a CSV file with columns:
    • Name (required)
    • Email (optional)
    • Phone (optional)
    • Site (optional: site handle exactly as spelled in Craft, like en, ar or site2En, or site ID like 1, 2; a value that names no site leaves the row on the default site chosen for the import)
  4. Choose whether to send invitations after import. This sends invitations only to the recipients this import creates; recipients that were already waiting are not sent until you run the campaign
  5. Click Import

CSV Format Example:

Name,Email,Phone,Site
John Doe,john@example.com,96512345678,en
Ahmed Ali,ahmed@example.com,96598765432,ar

Running Campaigns

Single Campaign

  1. Navigate to the campaign's recipient list
  2. Click Run Campaign
  3. Invitations will be queued and sent in batches

All Campaigns

  1. Navigate to Campaign Manager → Campaigns
  2. Click Run All
  3. All campaigns will be processed

Disabling a Campaign

Disabling a campaign for a site stops its invitations. A batch queued by adding a recipient, by an import, or by a campaign run sends nothing when it runs after the campaign was disabled for that site, however long it waited for an invitation delay; its recipients keep waiting. Enabling the campaign again does not resend those batches: run the campaign to queue its pending recipients again.

Invitation Delay

A campaign's Invitation Delay holds every invitation back until the recipient has been in the campaign for that long, counted from when the recipient was added. It applies however the invitation is queued: adding a recipient with Send Invitation, importing with Send invitations on, or running the campaign. Leave the delay empty to send right away.

  • A batch is queued to run only once every recipient in it is eligible. When a campaign run finds recipients who have already waited long enough, they are sent to right away; the others are batched in the order they become eligible.
  • The delay is read from the campaign again when a batch runs. Lengthening it after queueing holds the recipients back for the rest of the new delay; shortening or removing it lets them go out at once.
  • A recipient who is still waiting when a batch runs is not skipped: those recipients are queued again automatically, so nothing needs to be run twice.
  • A stored delay the plugin cannot read as a duration holds everything back rather than sending early: nothing is queued or sent for that campaign, and the value is never changed for you. Adding a recipient with Send Invitation still adds the recipient and tells you that the invitation was not queued; importing with Send invitations on still imports the recipients and tells you the same. A campaign run fails in the queue with a message naming the invalid delay, before any batch exists, and a batch that was already queued fails rather than sending. The plugin log names the invalid value. Correct the delay on the campaign (the edit form no longer accepts such a value), then run the campaign to send the waiting invitations.

SMS Pauses and Recovery

When SMS Manager reports an account or provider transport hold, Campaign Manager stops further SMS attempts in that batch. Unsent SMS recipients remain pending: they are not marked sent or counted as ordinary recipient failures. Successful SMS invitations already recorded stay recorded. Email continues independently for every eligible recipient, including recipients whose SMS is pending.

The campaign edit page and recipient list show a notice when an active hold applies to the campaign's resolved sender and effective main/development account. The notice includes a recovery time when available. View Transport Status links to the provider in SMS Manager only for users permitted to view that provider. Hold release remains in SMS Manager.

When creating a campaign, a short notice appears below Sender ID if the selected sender is on hold. It updates when you switch senders, including Use Campaign Manager default, and shows the recovery time for a timed hold. The SMS Manager link appears only when you have permission. These notices use the status read when the page loaded; reload the page after a hold changes. Saving a campaign does not send invitations.

Recovery depends on the result:

Result What Campaign Manager does
Guard busy Queues one delayed SMS-only continuation for this batch's remaining pending recipients, using SMS Manager's bounded delay. No provider attempt occurred.
IP blocked, rate limited, provider unavailable, or cooldown with a recovery delay Queues one delayed SMS-only continuation, no earlier than the supplied delay or the recipients' invitation delay.
Account unavailable, any hold without a recovery delay, or guard unavailable Finishes without automatic SMS recovery. Recipients remain pending for an explicit later Run Campaign.
Transport uncertain Finishes without automatic replay. Use the same manual recovery flow. A later explicit Run Campaign treats the unrecorded SMS as pending.
Invalid recipient or ordinary provider error Counts the ordinary failure and continues to later recipients, as before.

Continuations send SMS only and recheck campaign enablement, recipient ownership, pending status, responses and expiry. If another job already sent an invitation, it is not sent again. A later hold can postpone or stop recovery. No worker waits or sleeps for a hold to expire.

For manual recovery, resolve the underlying problem and release the applicable hold in SMS Manager. Then choose Run Campaign for the campaign and site. It picks up remaining pending recipients and any newly added eligible recipients; each channel skips its recorded successes. There is no separate review or resume screen.

Releasing a hold sends nothing and does not restart completed or failed jobs. Other jobs already queued may send when they run after release or timed expiry. A delayed continuation already queued still waits until its scheduled time; early release does not accelerate it. Historical failed jobs are never retried by this recovery path.

SMS Manager owns hold scope, admission and expiry. Account holds affect the matching credentials; transport holds affect the provider type across accounts. Unaffected providers and accounts remain eligible. Campaign Manager checks current status and still checks the actual send result, since another job can create a hold between those checks. Custom providers can opt into SMS Manager's shared transport contract; providers without that optional contract retain ordinary dispatch behavior.

A successful recorded send is not repeated. If a provider delivered an SMS but success was never recorded, it still appears pending. A timed continuation or an explicit later Run Campaign can resend that message. A recovery delay indicates when another attempt is allowed; it is not proof of non-delivery. Explicit transport-uncertain outcomes never create an automatic continuation.

Viewing Analytics

  1. Navigate to Campaign Manager → Analytics
  2. Use filters to select campaign, site, and date range
  3. View metrics across four tabs:
    • Overview: Key stats and campaign performance table
    • Delivery: Daily activity and channel distribution
    • Engagement: Open rates over time
    • Conversion: Funnel visualization and breakdown
  4. Export data using the Export button

A date range counts events by when they happened: recipients by when they were added, invitations by when they were sent, opens by when they were first opened, responses by when the form was submitted, and expired invitations by when they expired. All time covers every retained event. The same figures appear on the Analytics page, in its export, in the dashboard widgets, and on a campaign's own Analytics tab.

The Conversion tab's funnel is the one exception: it follows a single group, the recipients added inside the selected date range. As of the end of that range it counts how many of them had been sent an invitation on either channel, how many of those had opened one, and how many of those had responded, each recipient once. Every stage is a subset of the stage before it, so the percentages and drop-offs in the funnel table stay between 0% and 100%. Invitations, opens and responses of recipients added before the range are activity of the range and appear in the summary cards and charts, not in the funnel. All time follows every recipient through every retained event.

Disabling a campaign stops sending but keeps its history: it stays in the campaign filter, the campaign performance table, the export, and the performance widget.

How Responses Are Linked to Recipients

When a recipient submits the form from their invitation link, Campaign Manager links that Formie submission to the recipient. A submission is linked only when all of these are true:

  • The code in the page URL belongs to a recipient
  • The submitted form is the form assigned to the recipient's campaign
  • The submission was made on the recipient's site
  • The invitation has not expired
  • The recipient has no linked response yet

The first valid response is kept. Later submissions with the same invitation code never replace it.

Note

Campaign Manager never blocks a Formie submission. When a submission does not meet these conditions, Formie still saves it and still sends its notifications and integrations — it just isn't linked to a recipient or counted as a campaign response.

For headless or custom front ends, keep the code query parameter on the request that submits the form, and submit with the recipient's site.

Viewing Responses

  1. Navigate to a campaign and click Edit
  2. Click the Responses tab
  3. View all recipients who submitted the form
  4. Click "View" to see full submission details in Formie

Exporting Recipients

  1. Navigate to the campaign's recipient list
  2. Click Export
  3. Choose format (CSV, JSON, or Excel)
  4. Download includes all recipient data and status

Template Variables

campaignManager.campaigns

{# Get all campaigns #}
{% set campaigns = campaignManager.campaigns.find().all() %}

{# Get campaign by ID #}
{% set campaign = campaignManager.campaigns.find().id(123).one() %}

{# Get campaigns for a site #}
{% set campaigns = campaignManager.campaigns.find().site('en').all() %}

campaignManager.recipients

{# Get recipient by invitation code #}
{% set recipient = campaignManager.recipients.getRecipientByInvitationCode(code) %}

{# Mark recipient as opened #}
{% do campaignManager.recipients.markAsOpened(recipient) %}

{# Check recipient status #}
{% if recipient.hasSubmission() %}
    {# Already submitted #}
{% elseif recipient.invitationIsExpired() %}
    {# Invitation expired #}
{% endif %}

{# Get recipients with submissions for a campaign #}
{% set respondents = campaignManager.recipients.getWithSubmissions(campaignId, siteId) %}

Console Commands

Campaigns are run through the Control Panel or via the queue worker. There are no dedicated CLI commands for this plugin at this time.

Permissions

Campaign Permissions

  • Manage campaigns (grants campaign list access)
    • Create campaigns
    • Edit campaigns
    • Delete campaigns
    • Run campaigns

Recipient Permissions

  • Manage recipients (grants recipient list access)
    • Add recipients
    • Import recipients
    • Export recipients
    • Delete recipients

Analytics Permissions

  • View analytics
    • Export analytics

Logs Permissions

  • View logs
    • View system logs
      • Download system logs
    • View activity logs
      • Download activity logs
      • Clear activity logs

Settings Permissions

  • Manage settings

Site Access

On a multi-site install, Campaign Manager permissions work together with Craft's own site permissions. A Campaign Manager permission decides what a user can do; Craft's Sites permissions (Edit “Site name”) decide where they can do it.

For example, a user with Edit campaigns and Delete recipients who can only edit the French site can change the French version of a campaign and delete French recipients, but cannot open, change, or run anything on the other sites. Deleting a campaign is different, because a campaign is shared by every site; see below.

This applies everywhere in the control panel:

  • Campaigns: opening, saving, deleting, and running a campaign require access to the site being worked on. Running without choosing a site queues only the sites the user can edit.
  • Recipients: lists, the add and import screens, and deletion only reach recipients on sites the user can edit. When a bulk delete includes recipients from other sites, those recipients are left in place and reported as not deleted.
  • Analytics and responses: "All Sites" means all sites the user can edit, including the Analytics and Responses tabs on a campaign. A user who cannot edit any site sees no campaigns and no numbers.
  • Dashboard widgets: a widget checks its chosen site every time it loads. If the user loses access to that site, the widget shows No data available until they pick another site in the widget settings.
  • Activity logs: every log records which sites it covers. A user sees a log only when they can edit every site it covers, so a bulk delete or an export that touched two sites is shown only to users who can edit both. Logs that cover every site (saving or deleting a campaign) and logs written before site coverage was recorded are shown only to users who can edit every site. Clearing the logs removes the logs of every site, so Clear activity logs also requires being able to edit every site.

Users who can edit every site are not affected. To give someone access to a site, grant the matching Edit site permission under Settings → Users → User Groups (or on the user's Permissions tab).

Front-end templates and code that call craft.campaignManager.analytics directly are not tied to a control panel user for the per-campaign statistics: getCampaignStats() and getCampaignDailyTrend() cover every site when no site is given, as before. The control panel passes the user's editable sites to them explicitly.

The rating analytics methods (getRatingFieldsInScope(), getRatingStats(), getCampaignRatingBreakdown(), getRatingTrend(), getRatingDistribution() and getRawRatingResponses()) follow the same rule: a site scope of 'all' means every site of the project, including disabled sites, whoever is calling. Their answer for 'all' is therefore the same for a front-end template, a console command, or any control panel user. The control panel never passes 'all' to them; it passes the IDs of the sites the current user can edit, so the Ratings tab shows only those sites.

A campaign exists on every site, the same way a Craft entry can. Saving it on one site also updates the settings that are shared between sites, and deleting it from any site the user can edit deletes the campaign on every site.

Events

use lindemannrock\campaignmanager\services\RecipientsService;
use lindemannrock\campaignmanager\events\RecipientEvent;
use yii\base\Event;

// Before sending invitation
Event::on(
    RecipientsService::class,
    RecipientsService::EVENT_BEFORE_SEND_INVITATION,
    function(RecipientEvent $event) {
        // Access: $event->recipient, $event->campaign
        // Set $event->isValid = false to cancel
    }
);

// After sending invitation
Event::on(
    RecipientsService::class,
    RecipientsService::EVENT_AFTER_SEND_INVITATION,
    function(RecipientEvent $event) {
        // Access: $event->recipient, $event->success
    }
);

Troubleshooting

Invitations Not Sending

  1. Check SMS Manager is configured: Ensure providers and sender IDs are set up
  2. Check Bitly API key: Required for SMS URL shortening
  3. Check queue is running: ./craft queue/run
  4. Check logs: Campaign Manager → Logs

SMS Remains Pending While Email Is Sent

Check the hold notice on the campaign edit page or recipient list. An applicable account or provider transport hold pauses SMS without stopping email. Follow SMS Pauses and Recovery; do not retry old failed jobs to clear a hold. Resolve and release a manual hold in SMS Manager, then use Run Campaign. Recorded successful sends are skipped; a message delivered without a recorded success remains pending.

Survey Page Not Loading

  1. Verify invitation code: Check the URL has a valid code parameter
  2. Check recipient exists: The invitation code must match a recipient record
  3. Check campaign has form: The campaign must have a Formie form assigned

Response Saved in Formie but Not Linked to a Recipient

Symptom: A submission appears in Formie, but the recipient still shows no response and the campaign's response count did not change.

Quick checks:

  1. Invitation code: The request that submitted the form must include the recipient's code query parameter. Custom templates and headless front ends need to keep it on the submit request.
  2. Form: The submitted form must be the one assigned to the recipient's campaign.
  3. Site: The submission must be made on the recipient's site.
  4. Expiry: The invitation must not have expired.
  5. Earlier response: A recipient keeps their first linked response. Later submissions with the same code are saved by Formie but not linked.

Fix: For a missing code, wrong form, or wrong site, correct the cause and have the recipient submit again from their invitation link. An expired invitation, or a recipient who already has a linked response, cannot be linked again. With the log level set to Warning, Info, or Debug, Campaign Manager → Logs records why a submission with an invitation code was not linked.

Why: Invitation links are personal, so a response is only counted for the recipient, form, and site it was issued for, and only once.

CSV Import Failing

  1. Check CSV format: Must have Name column at minimum
  2. Check encoding: Use UTF-8 encoding for special characters
  3. Check file size: Large files are processed in batches

Settings Save Shows a Validation Error

Numeric settings such as activity log retention, activity log limits, and items per page must be whole numbers within the allowed range. If a value is invalid, Campaign Manager keeps you on the same settings page and shows the field error inline.

When a setting is overridden in config/campaign-manager.php, the Control Panel field is skipped during save. Change the config file value instead.

Bitly URLs Not Working

  1. Verify API key: Check BITLY_API_KEY environment variable
  2. Check API limits: Bitly has rate limits on free plans
  3. Fallback: If Bitly fails, original URLs are used

Logging

Campaign Manager uses the LindemannRock Logging Library for system logging.

Log Levels

  • Error: Critical errors only (default)
  • Warning: Errors and warnings
  • Info: General information
  • Debug: Detailed debugging (requires devMode)

Log Files

  • Location: storage/logs/campaign-manager-YYYY-MM-DD.log
  • Retention: 30 days (automatic cleanup)
  • Web Interface: View logs at Campaign Manager → Logs

Support

License

This plugin is licensed under the Craft License. See LICENSE.md for details.

Developed by LindemannRock