goldnead/statamic-marketing

Email marketing and newsletters directly inside your Statamic Control Panel — lists, double opt-in, campaigns, sending, and tracking built on LeadHub contacts.

Maintainers

Package info

github.com/goldnead/statamic-marketing

Type:statamic-addon

pkg:composer/goldnead/statamic-marketing

Transparency log

Statistics

Installs: 66

Dependents: 2

Suggesters: 4

Stars: 0

Open Issues: 0

v1.10.0 2026-08-03 05:34 UTC

README

Email marketing and newsletters directly inside your Statamic 6 Control Panel — mailing lists, double opt-in, campaigns, batch sending, and open/click tracking. Think Mailcoach, but native to Statamic and built on top of LeadHub contacts.

The addon family

Addon Role
statamic-leadhub CRM: contacts, tags, timeline — required, subscribers are LeadHub contacts
statamic-brand-context Required as a package. Multi-brand mode is off by default; the package is not optional
statamic-suppression Required. The gate every send path asks before it delivers
statamic-preference-center Optional: one preference page over marketing lists, notification types and suppression. Installing it moves every footer link there automatically
statamic-webhook-manager Optional: ESP feedback webhooks in (bounces/complaints), marketing events out
statamic-automations Optional: marketing triggers & actions for visual drip workflows
statamic-marketing Lists, opt-in, campaigns, sending, tracking

Features

  • Mailing lists with per-list double opt-in (confirmation mail + tokenized confirm link) and honeypot-guarded public subscribe endpoint.
  • Campaigns composed in Antlers ({{ first_name }}, {{ name }}, {{ email }}, {{ unsubscribe_url }}, …), wrapped in reusable email templates, with preview, test send, scheduling, and send-now.
  • Segment targeting: optionally narrow a campaign's audience to a LeadHub segment (with a live member count in the CP). The audience is subscribed list members ∩ segment members, resolved at send time — the segment only narrows; consent always comes from the list. No segment = the whole list. Requires LeadHub ^1.1; degrades gracefully (whole-list send) on older LeadHub.
  • Queued batch sending through any Laravel mailer with configurable throttle, per-recipient message records, and automatic finalization.
  • Tracking: open pixel, signed click redirects, per-campaign reports (open/click rates, bounces, unsubscribes).
  • Newsletter web archive: a public, linkable web version of a campaign on a readable URL (/newsletter/{handle}), plus a chronological index and an RSS feed, with title, description, canonical and Open Graph tags. Visibility is per campaign and off by default — a campaign is released from its report page after it has been sent. The web version is the depersonalised edition: the same renderer, but no open pixel, no rewritten links (an open on a web page is not an open of the e-mail) and personalisation placeholders resolved to a neutral word. An unreleased campaign answers 404, not 403 — a 403 would confirm that it exists.
  • Frequency caps: an upper bound on marketing mail per contact per rolling window (default off). Every outgoing mail carries a classification — marketing, transactional, digest, reminder — and the cap acts on marketing alone: the community digest does not eat the budget, a password reset is never delayed, an event reminder goes out regardless. The decision is taken when the message is sent, not when it is queued, so a job that waited three days is measured against the window that ends now. A capped message is deferred and retried, and only discarded — with status = capped and a log entry — once its deferral budget runs out.
  • Unsubscribes via tokenized link plus RFC 8058 one-click (List-Unsubscribe / List-Unsubscribe-Post headers), optional global opt-out to LeadHub's do_not_contact.
  • Unsubscribe, always: a tokenized link ends one list and says so, with no login and no optional package involved. Where statamic-preference-center is installed, every footer link goes there instead — one page for all of this brand's lists, notification types and the suppression state. Marketing does not ship a second copy of that page; the switch is a single resolver (src/Support/PreferenceLink.php), so nothing has to be reconfigured when the addon appears or goes away. The RFC 8058 one-click endpoint stays on marketing either way.
  • LeadHub native: subscribing upserts the contact, records timeline events (marketing.subscribed / marketing.unsubscribed), and tags contacts with list:{handle}. Hard bounces and complaints opt the contact out.
  • Flat-file first: lists, campaigns, and templates live as YAML under content/marketing/ (version-controllable, the Statamic way) — or in the database via MARKETING_DRIVER=eloquent. Runtime data (subscriptions, messages, events) is always Eloquent.
  • Sibling integrations (auto-detected, both optional):
    • Webhook Manager: marketing events become outbound webhook triggers; the marketing.process_esp_event inbound action maps Mailgun/Postmark bounce webhooks onto subscriptions.
    • Automations: triggers (marketing.subscribed, marketing.unsubscribed, marketing.campaign_sent) and actions (marketing.subscribe, marketing.unsubscribe, marketing.send_campaign) in the visual builder.

Screenshots

Marketing dashboard Mailing lists
Audience and recent campaign performance at a glance Lists with double-opt-in status and live subscriber counts
Campaign composer Campaign report
Antlers content, sender, scheduling, test send Delivery, open and click rates, per-recipient log

Requirements

PHP 8.2+ (8.3+ when running Laravel 13)
Laravel 12 or 13
Statamic 6
Required addons goldnead/statamic-leadhub, goldnead/statamic-brand-context, goldnead/statamic-suppression
Optional addons goldnead/statamic-preference-center, goldnead/statamic-webhook-manager, goldnead/statamic-automations
Also needed a queue worker (campaign delivery) and the Laravel scheduler (scheduled campaigns)

All three required addons are installed for you by Composer. brand-context is required as a package even on a single-brand site — only its multi-brand mode is optional.

Installation

Not yet installable from Packagist. The three required sibling addons are private repositories, and Composer only reads the repositories block of the root project — never of a package it is installing. So the plain composer require goldnead/statamic-marketing cannot resolve them and will fail. Until the siblings are published, add the repositories to your own composer.json first:

// your project's composer.json
"repositories": [
    { "type": "vcs", "url": "https://github.com/goldnead/statamic-brand-context.git" },
    { "type": "vcs", "url": "https://github.com/goldnead/statamic-leadhub.git" },
    { "type": "vcs", "url": "https://github.com/goldnead/statamic-suppression.git" }
]
composer require goldnead/statamic-marketing
php artisan migrate

Publish the config if you want to tweak defaults:

php artisan vendor:publish --tag=marketing-config

Make sure a queue worker is running for campaign delivery, and the Laravel scheduler for scheduled campaigns (marketing:send-scheduled runs every minute).

Frontend signup form

{{ marketing:subscribe list="newsletter" class="newsletter-form" }}
    <input type="email" name="email" required placeholder="you@example.com">
    <input type="text" name="first_name" placeholder="First name">
    <button>Subscribe</button>
{{ /marketing:subscribe }}

Or POST to {{ marketing:subscribe_url }} yourself (email, list, optional first_name, last_name, _redirect). JSON clients receive { "ok": true, "data": { "status": "pending|subscribed" } }.

Multi-brand

Optional, off by default. With goldnead/statamic-brand-context in multi-brand mode both storage drivers isolate lists, campaigns and templates per brand — the eloquent driver by brand_id, the flat driver by directory:

content/marketing/
  acme/lists/newsletter.yaml
  contoso/lists/updates.yaml

Single-brand installs need to do nothing. They keep the plain content/marketing/lists/… layout, and files still in it are read as the default brand's even after multi-brand is switched on. Once a second brand exists, move them into the default brand's directory:

php artisan marketing:migrate-flat-brands --dry-run   # show the moves
php artisan marketing:migrate-flat-brands             # do them

It only ever moves; it never overwrites, never deletes, and a second run is a no-op. --brand= picks a different target brand.

Checking the consent guarantee

One address on one list is one consent record, and the database is what enforces it. php artisan migrate reporting success says the migrations ran; it does not say the constraints they were supposed to leave behind are there. This asks the second question directly:

php artisan marketing:consent-integrity            # report only, changes nothing
php artisan marketing:consent-integrity --repair   # rebuild the index, if nothing is in the way

It reads the indexes on marketing_subscriptions as they are right now and the rows in it, names any list/address pair holding more than one subscription with each row's id, status and confirmation date, and exits non-zero if the guarantee is not in force. It never deletes a subscription: which of two sign-ups is the consent record is a decision about people, and --repair refuses to build the index while anything would have to go for it.

Worth running once after any update that touched migrations, and in particular on an install that came from 1.2.1 or earlier through 1.6.1–1.6.3 — see the 1.6.4 entry in CHANGELOG.md.

List handles are unique across all brands in both drivers. The public subscribe endpoint derives the brand from the list handle the form names — no brand in the URL, no session, nothing for a visitor to get wrong — and that only holds while a handle has exactly one owner. Creating a duplicate is refused with a message naming the brand that holds it.

Configuration highlights (config/marketing.php)

Key Default Purpose
storage.driver flat flat (YAML in content/marketing/) or eloquent
sending.mailer app default Laravel mailer for campaigns
sending.messages_per_minute 0 Throttle for ESP rate limits (0 = off)
subscriptions.double_opt_in true Default for new lists (per-list override)
unsubscribe.global_opt_out false Also set LeadHub do_not_contact on unsubscribe
tracking.opens / tracking.clicks true Toggle tracking
delivery.mail_headers [] Per-message headers asking the provider not to rewrite links
delivery.ignored_query_parameters 11 provider names Parameters a click counter may append without breaking the signed redirect
leadhub.tag_subscribers true Tag contacts with list:{handle}
frequency_cap.enabled false Off until you turn it on — updating the package changes no send
frequency_cap.max / .window_hours 3 / 168 Three marketing mails per seven days
frequency_cap.defer.* 1440 min, 3 tries How long a capped message waits, and how often, before it is discarded and logged
archive.enabled true The archive routes; per-campaign visibility is still off by default
archive.prefix newsletter Path for the index, feed.xml and each campaign page
archive.neutral_name null Stands in for {{ first_name }} / {{ name }} on the web version (falls back to the translation)

Classifying mail from another addon

The classification is a contract, not a marketing internal — a cap is only as good as its exceptions, and the addon doing the sending is not the one that knows what a mail is for. Any package in the family can name a class and ask before it sends:

use Goldnead\Marketing\Contracts\FrequencyCap;
use Goldnead\Marketing\Contracts\MailClass;

if (app(FrequencyCap::class)->allows($email, MailClass::Marketing, $brandId)) {
    // …send…
    app(FrequencyCap::class)->record($email, MailClass::Marketing, $brandId, 'my-addon:weekly');
}

allows() is always true when the cap is off and always true for a class the cap does not act on, so a caller never has to know the exceptions. Unknown or absent reads as marketing: forgetting to classify costs a delay, never an exemption nobody asked for. Counting is keyed on the normalized address, so somebody on four lists is still one person with one budget.

The check falls open — a cap that cannot count says yes and logs it. That is the deliberate opposite of Goldnead\Suppression\Contracts\Gate, which falls closed and aborts the send: suppression is the only thing between a send and somebody who said no, while the cap is between a send and somebody who has been hearing from you a lot.

Publishing an issue to the web archive

Open the campaign's report page and switch Publish a public web version on. It takes effect once the campaign has actually been sent, and switching it off removes the page on the next request — nothing caches the list. Under multi-brand the index and the feed show whichever brand is current for the request; a campaign page derives its brand from the handle, the same way the subscribe endpoint derives one from a list handle.

Sending through a provider that counts clicks

Brevo, Mailgun, Mailchimp and most others rewrite every href in the HTML part onto a counter of their own and forward the reader with an extra parameter attached. The click redirect this addon signs does not survive that on its own: Laravel signs the whole query string, so one appended parameter is a 403 — the reader never arrives and the click is not counted either.

delivery.ignored_query_parameters names the parameters that may be appended without invalidating the signature. It ships with the eleven that real providers add, and it will not ignore url, expires or signature however it is edited: this route carries its destination in the query, so an ignorable url would be an open redirect on your own domain rather than merely a weaker signature.

delivery.mail_headers is the other half — the per-message header that switches the provider's own counter off, which most of them offer and Brevo does not. It is empty by default; config/marketing.php has the verified table of names.

Testing

composer install
vendor/bin/pest                     # flat driver (default)
MARKETING_DRIVER=eloquent vendor/bin/pest   # eloquent driver

# Live cross-addon integration suite (installs automations + webhook-manager
# into a throwaway copy; point the *_PATH vars at local checkouts):
AUTOMATIONS_PATH=../statamic-automations \
WEBHOOK_MANAGER_PATH=../statamic-webhook-manager \
scripts/test-siblings.sh

CI note: goldnead/statamic-leadhub is a private sibling repo, so the GitHub Actions workflows need a SIBLING_REPOS_TOKEN repository secret (a PAT with read access to it) to check it out next to this package.

Against a real MySQL server

vendor/bin/pest -c phpunit.mysql.xml
MARKETING_DRIVER=eloquent vendor/bin/pest -c phpunit.mysql.xml

Same tests, DB_DRIVER=mysql; point DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORD at a throwaway database, which the suite migrates from scratch on every test.

SQLite is not a substitute for it. It has no InnoDB key-length limit, stores no fixed column widths and has no per-character byte cost, so a green SQLite run says nothing about whether MySQL can build this schema at all — the blind spot that took statamic-notifications down on production. tests/Unit/IndexKeyLengthTest.php covers that class of defect without needing a server: it compiles the addon's own migration files through Laravel's MySQL grammar in pretend mode and measures every index the way InnoDB would, including whether a unique covers a column that may be NULL and therefore constrains nothing.

Component tests (Vitest)

npm install
npm test               # or: npx vitest run   /   npx vitest  (watch)

The Control Panel is a Vue SPA, and until 1.6.1 nothing in this package could execute a line of it. PHPUnit reaches the controller and the props it hands over; tests/Feature/CpValidationVisibilityTest.php reads the .vue sources and proves the error wiring is present. Neither can say whether a rejected form actually shows the message — that sat between them, and a screenshot was the only evidence there was.

Vitest closes that gap. It is deliberately narrow:

  • What belongs here: logic inside a component — computed fallbacks, which operator a stored false or 0 has to survive, where an error is rendered, what a component is handed.
  • What does not: navigation, saving, permissions end to end, anything crossing into PHP. Those are feature tests.

Setup notes, in case something fails at an import rather than at an assertion:

  • Vitest reads the same vite.config.js. Under VITEST the Statamic Vite plugin is swapped for the plain Vue plugin, because the former rewrites vue to window.Vue — correct for the CP bundle, fatal in a test process.
  • @statamic/cms/ui and @statamic/cms/inertia are re-export shims that destructure a __STATAMIC__ global the CP installs at runtime. tests/js/setup.js installs it first and answers every requested name with a stub component that mirrors its attributes into the DOM, so a test can assert what a component was handed without pinning down CP markup that is not ours. It also installs __ as a real global, because a <script setup> block calls the translator directly and Vue Test Utils' mocks only reach templates.

License

MIT