goldnead/statamic-preference-center

One page for everything a person receives — mailing lists, product notifications, cadence and the blocks that override all three — assembled from whichever of the three sources is installed.

Maintainers

Package info

github.com/goldnead/statamic-preference-center

Type:statamic-addon

pkg:composer/goldnead/statamic-preference-center

Transparency log

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.3.0 2026-08-01 17:05 UTC

This package is auto-updated.

Last update: 2026-08-01 21:43:40 UTC


README

Statamic Preference Center

One page for everything a person receives. The mailing lists they consented to, the product notifications they can switch per kind and per route, how often those arrive, and the blocks that override all three. It owns no table and invents no setting: every value on the page is read from and written back to the package that already owns it.

Before this existed there were three truths in three places and no page that showed a person all of them. The unsubscribe link was the only door, it worked on one list at a time, and it was a dead end — it did not even reveal that the same person was still on four other lists of the same brand.

Why it is its own package

It is a view, and a view over three packages belongs in none of them.

statamic-marketing owns consent to editorial mail. statamic-notifications owns the type × channel matrix and the digest cadence. statamic-suppression owns the answer to whether an address may be mailed at all. Putting the combined page in any one of them would make that one depend on the other two, and the whole point of the arrangement is that each works alone.

All three are suggest, not require. A host with only notifications gets a working page. So does a host with only marketing. That is not politeness — in the Hub this centre is set up for a brand whose mix is not the mix on the site it was first designed for.

It replaces marketing's preference page

goldnead/statamic-marketing used to serve a preference page of its own at /!/marketing/preferences/{token}. It does not any more. This package owns the preference page for the whole family; marketing keeps only a one-click unsubscribe path that works whether or not this package is installed, and routes every preference link it writes through a resolver that prefers the page here.

If you are installing this next to a marketing that predates the split, read UPGRADE.md first. It is one page and it covers what happens to links already sitting in people's inboxes.

Requirements

PHP 8.2 or newer
Laravel 12.40 or newer, or 13
Statamic 6
Database any Laravel-supported driver; this package owns no table of its own

Two hard requires, both from the same family: goldnead/statamic-brand-context and goldnead/statamic-identity-contracts. The three data sources — marketing, notifications, suppression — are all optional and detected at runtime.

Install

Until the sibling packages are on Packagist, the composer require below is not enough on its own. Composer only reads the repositories key of the root project, never of a package it installs as a dependency, so the entries in this package's composer.json do nothing for you. Add them to your own composer.json first:

"repositories": [
    { "type": "vcs", "url": "https://github.com/goldnead/statamic-brand-context.git" },
    { "type": "vcs", "url": "https://github.com/goldnead/statamic-identity-contracts.git" }
]

Some of these repositories are private. Composer needs a GitHub token with read access to them:

composer config --global --auth github-oauth.github.com <token>

Then:

composer require goldnead/statamic-preference-center

The routes mount themselves under !/preference-center.

php artisan vendor:publish --tag=preference-center-config
php artisan vendor:publish --tag=preference-center-views
php artisan vendor:publish --tag=preference-center-translations

Point the digest footer at it:

NOTIFICATIONS_PREFERENCES_URL="https://example.com/!/preference-center"

The three doors

Three ways in, and all three end at one Identity from goldnead/statamic-identity-contracts — which exists precisely because a token holder has no account, a logged-in person has no subscription token, and something has to carry a user id, a contact uuid, an address and an anonymous id side by side.

Door URL Identity Proof
Token from a marketing mail /!/preference-center/t/{pcToken} contact, from the subscription's contact_uuid, else located by address unsubscribe_token
Signed link, on request /!/preference-center/link/{pcLink} → session contact, located by the address sealed in the link magic_link
Authenticated session /!/preference-center whatever IdentityContext::resolve($user) returns session

The brand comes from the door, never from the session: SetBrandFromRouteValue derives it from the token, and a magic link carries its brand sealed inside it. A page that inherited the brand from whatever the browser was last looking at would show one audience's lists to another's.

The identity rule that is easy to get wrong in the friendly direction

notification_preferences is matched on user_id and contact_uuid with =, never OR. So the identity this page writes must be the identity the sender reads. The session door therefore hands over exactly what IdentityContext::resolve() produced, unimproved — helpfully attaching a contact uuid the sender does not know about would write preferences into a row nothing ever reads.

And where no identity can be established at all, nothing is stored. Both keys NULL is not a row the database rejects; it is one row, shared by every unplaceable visitor, because a hash of two NULLs is the same hash every time. Those controls render locked and the write path refuses them twice.

The three limits

Set in decision L15, and none of them is negotiable.

1. required() types stay unswitchable. For anybody, through any door. The lock cannot live in the preference layer: PreferenceResolver::allows() returns true for a required type on every channel before it reads anything stored — correct for a sender, useless for a form, because a cell that is on and must stay on looks exactly like a cell that is on and may be turned off. So it lives in the view and in the write path.

2. A token does not lift a block. Bounce, complaint and manual opt-out survive all three doors. The two sources of a block — the contact's own opt-out and the suppression table — are read the way statamic-marketing 1.8.1 reads them: batched, per row, and fail-closed. An unqueryable gate is not a third opinion between blocked and clear; it is the closed answer, and the page says which of the two it is rather than leaving a visitor hunting for a block that does not exist.

What a block does not stop is somebody withdrawing consent. Less mail is always allowed.

3. Every change is recorded with the proof that authorised it. unsubscribe_token, magic_link or session — a PreferencesChanged event, a structured log line, and a LeadHub timeline entry where LeadHub is installed. Marketing already recorded a proof for its own writes, but only ever the one value it knew about; a record that names the wrong door is worse than no record, because it would be relied on.

The magic link

The one thing this package builds rather than borrows, and therefore the one whose security is its own fault.

  • Signed and expiring, via URL::temporarySignedRoute. Default 30 minutes, configurable.
  • Encrypted payload, not merely signed. The signature already makes it unforgeable; the encryption keeps the address out of access logs, Referer headers and browser history.
  • It does not say whether an address exists. Sent, no such person, blocked, throttled — the same page, byte for byte, and held open to a floor so the outcome cannot be read off a stopwatch either. Identical wording with a 12 ms "no such person" and a 340 ms "mail sent" is an enumeration oracle with good manners.
  • Throttled twice, by address and by origin. One without the other is not a limit: per-address alone lets one client mail ten thousand different people, per-origin alone lets ten thousand clients mail one person. Both limiters count addresses nobody has heard of, or the limiter itself becomes the oracle.
  • A blocked address gets nothing. "Here is your link to manage preferences" is still mail, and that mailbox is the one the provider told us to stop writing to.
  • An unknown address gets nothing. Otherwise the endpoint mails a signed link to anything typed into it, which is an open relay with extra steps.
  • The address names the brand, not the visitor. The form has two fields, _token and email, and no brand field. Every other entrance derives its brand from something the visitor could not choose; this one has nothing to derive from, because an address is not yet known to belong anywhere and that is the question being asked. So the lookup runs in every brand, and the mail carries one link per brand that has heard of this address — normally exactly one. Nothing is revealed by that: the page says the same sentence either way, and the only person who learns which brands know the address is whoever reads that mailbox.
  • ?pcBrand=<handle> still narrows it, carried into the POST as a hidden field, for a site that belongs to one brand of a multi-brand host. It cannot widen the search, and an unknown handle behaves exactly as no hint at all.
  • Two bodies, one message. text/plain and text/html in a multipart/alternative. A signed link is around three hundred characters, and as running text alone it is wrapped by the clients that do not linkify and guessed at by the ones that do. The two parts escape the URL in opposite directions and both are right — see the note in each template, and the tests that follow the link out of each body rather than reading it.

What a click counter does to it, and what this package does about that

A signed URL survives the post and does not always survive the courier. Providers that count clicks rewrite every href in the HTML part onto their own redirector and append their own parameters when they forward the reader — Brevo appends _se, the recipient address in base64. Laravel signs the whole query string, so the URL that arrives is not the URL that was signed and the answer is 403. Measured on staging over a real Brevo send, on a link that passed the whole test suite: 302 …sendibt3.com/tr/cl/… → 403 …/link/…?_se=…&expires=…&signature=…. The plain-text link in the same message, which Brevo leaves alone, worked. Nothing local can find this: no mail sink rewrites links, which is exactly what makes a sink a sink.

Two answers, and a host wants both.

  • delivery.mail_headers — stop the rewriting. Most providers take a per-message header that turns click tracking off for that one message, which is the right setting for a link that is transactional and has no click rate anybody wants. Whatever is in the map is added to the outgoing message verbatim, so this package presumes no provider: X-Mailgun-Track-Clicks: no, X-PM-TrackLinks: None, X-Mailjet-TrackClick: 0, X-MSYS-API, X-SMTPAPI, X-MC-Track — the table with the exact values is in the config file. Empty by default; an addon that guessed your provider and changed how it behaves would be the worse neighbour.

    Brevo has no such header, and that is not an oversight in this README. X-Mailin-custom, X-Sib-Sandbox and X-SIB-API are the documented ones and none of them touches tracking; the transactional API has no tracking option in its body either; Brevo has declined the request for years in its own community forum. On Brevo the ignore list below is not defence in depth, it is the only thing that works.

  • delivery.ignored_query_parameters — survive it. These names are left out of the signature check, so a rewritten link still opens. Ignoring is giving away, so the list is short, every entry names the provider that adds it, and two names can never be on it: expires and signature are stripped out by TrackingParameters however the config is edited. What that leaves is bounded on purpose — the payload of a magic link is encrypted into the path, not the query, so there is nothing in the query for this route to protect except its lifetime, and its lifetime stays signed. A parameter nobody named is still refused, an edited payload is still refused, and a link past its time is still refused, each with a test that says so.

What following one does to the session

The link is spent on arrival and leaves a short-lived note in the session, with its own expiry. Two consequences that are not decoration:

  • The session id is regenerated, and the old one destroyed. From the click onwards the session id is the credential, so anything that handed somebody an id they did not create — a shared machine, a forwarded URL, a neighbouring subdomain — would otherwise hand them the page.
  • A login ends the note. It outranks an authenticated session on purpose, because whoever just followed a link is asking about the address in that link. But signing in says somebody else is at the keyboard now, and from there the session door takes over. Login only: the person who was already signed in when they clicked their own link keeps the note.

What it deliberately does not have

No table, so no single use and no revocation. A link is good for its lifetime, which is minutes, while the marketing token that opens the same page is good forever — that is the exposure that actually governs, and it is the one L15 named and accepted. Shorten the lifetime rather than reaching for a table.

The cadence, over storage that holds two of its four words

notification_preferences.frequency accepts daily and weekly. There is no immediate and no never in that addon, and inventing columns for them would make this package the owner of a data model it is supposed to be a view over. So the other two are expressed as the channel state they actually describe:

Choice mail digest frequency
Immediately on off
Daily off on daily
Weekly off on weekly
Never off off

Four distinct stored states, so a choice reads back as the choice that was made. never leaves the in-app channel alone: it is a cadence for mail, and a page inside the product is not a mailbox. Required types are not touched by any of the four.

The matrix can also hold a state that is none of the four — one type mailed as it happens beside one that is collected. Defaults alone produce it. The control then selects nothing and says the state is mixed, rather than rounding it to the nearest word and putting a caption on the page that the page's own data contradicts.

The cadence is a blunt control: it rewrites the mail and digest channel of every optional type. Two things keep it from flattening a matrix somebody just tuned by hand. It runs only when the choice actually changed — resubmitting the same word writes nothing. And when a submission carries a cadence and a cleared checkbox, the checkbox wins: the cadence writes first, then every cell whose posted value differs from the value the page rendered is written over the top of it. That set is exactly the boxes somebody clicked, so an untouched cell keeps what the cadence gave it and a clicked one keeps what the person asked for.

What it stores

Nothing. There are no migrations, and that is the answer to two traps that took a sibling package down twice: an index too wide for InnoDB, and a unique containing a nullable column, which constrains nothing at all for the rows where it is null. Neither is visible on SQLite. A package that owns no table cannot build either.

Routes

Name Method URI
preference-center.show GET /!/preference-center
preference-center.update POST /!/preference-center
preference-center.token GET /!/preference-center/t/{pcToken}
preference-center.token.update POST /!/preference-center/t/{pcToken}
preference-center.request GET /!/preference-center/request
preference-center.request.send POST /!/preference-center/request
preference-center.link GET /!/preference-center/link/{pcLink}

Every parameter is prefixed pc. A Route::bind() is application-wide, not per package: a binding another addon registers for {token} or {link} applies to every route with that name in every installed package and resolves it against a repository that has never heard of these values. That is exactly how goldnead/statamic-leadhub 1.8.0 shipped a delete button that did nothing.

The token routes are registered only where marketing is installed, because their brand middleware names a marketing model. The route table is therefore a function of what was installed at boot: after adding or removing goldnead/statamic-marketing on a host that runs php artisan route:cache, run php artisan route:clear or the cached table and reality disagree in silence.

The public contract

Three things here are public interface, bound by semver from the release that introduced them (see CHANGELOG.md). Everything else in src/ is free to change in a patch.

1. The route names

preference-center.token, preference-center.show and preference-center.request, also available as constants (PreferenceCenter::ROUTE_TOKEN, ::ROUTE_SHOW, ::ROUTE_REQUEST) so a sibling never has to type them.

2. Link discovery — how another package sends someone here

This is the interface goldnead/statamic-marketing uses to route its preference links at the combined page when it is installed, and at its own one-click path when it is not.

use Goldnead\PreferenceCenter\PreferenceCenter;

$url = class_exists(PreferenceCenter::class)
    ? app(PreferenceCenter::class)->urlForToken($subscription->token)
    : null;

$url ??= route('marketing.unsubscribe', $subscription->token);   // the path that always works
Method Returns
urlForToken(string $token): ?string Absolute URL of the combined page for the person a marketing subscription token names. null when this package cannot serve it.
requestUrl(): ?string Absolute URL of the magic-link door, for a sender holding no token. null when the routes are not mounted.

Two rules, both paid for already:

  • Probe class_exists() on the class, never method_exists() on the facade. A facade answers through __callStatic, so method_exists(Facades\PreferenceCenter::class, 'urlForToken') is false while the method exists — the reading that took every LeadHub action node down in goldnead/statamic-automations v1.0.3. If you must probe a facade, probe its getFacadeRoot().
  • null means "use your own path". It is returned when the routes are switched off, when marketing is absent or disabled, or when the token is empty. A caller that ignores it publishes a dead link into a mail nobody can recall.

3. The PreferencesChanged event

Fired once per accepted write, after it has been persisted:

use Goldnead\PreferenceCenter\Events\PreferencesChanged;

Event::listen(PreferencesChanged::class, function (PreferencesChanged $event) {
    $event->access;          // Data\Access — identity, brand, proof
    $event->changes;         // list<array{block, target, channel, to}> — only what actually changed
    $event->consentProof();  // 'unsubscribe_token' | 'magic_link' | 'session'
});

The package's own listener (RecordPreferenceChange) writes the audit line and, where LeadHub is installed, the contact-timeline entry. Yours runs alongside it.

The PreferenceCenter facade (aliased as PreferenceCenter) exposes view(), marketingCenter(), urlForToken() and requestUrl().

Reading the page in a test

The rendered page carries data-* attributes for every state it shows, so a check can quote what the page is showing instead of a person squinting at a screenshot:

data-list="<handle>"       data-state="active|inactive|blocked"
data-cell="<type>.<chan>"  data-state="on|off|locked-on|locked-off"  data-reason="required|blocked|unidentified"
data-block="lists|types|frequency|none"
data-frequency="immediate|daily|weekly|never|mixed"
data-suppression="blocked|unavailable"
data-proof="unsubscribe_token|magic_link|session"

Configuration

See config/preference-center.php. The values worth knowing:

Key Default What it decides
routes.prefix !/preference-center Not /preferences: a host that owns that URL should not have to fight this addon for it
sources.* auto false turns a block off even where the package is installed. Nothing turns one on where the classes are missing
magic_link.ttl_minutes 30 Life of the signed URL
magic_link.min_response_ms 350 The floor under a link request. Raise it above your mailer's latency
magic_link.throttle.* 3/hour per address, 10/hour per origin
magic_link.allow_unknown_addresses false Leave it off unless nobody is known yet
delivery.mail_headers [] Per-message headers that tell your provider not to rewrite the links. Verified values per provider in the config file
delivery.ignored_query_parameters _se, the five utm_*, mc_cid, mc_eid, _hsenc, _hsmi, mkt_tok Left out of the signature check because a provider appends them. expires and signature cannot be added
audit.log_channel default channel

Personal data

Nothing of its own. The page reads and writes through the packages that already own the values: subscriptions in goldnead/statamic-marketing, the type × channel matrix and the cadence in goldnead/statamic-notifications, the block state in goldnead/statamic-suppression. Deleting a person's data is done in those packages; there is no table here to clean up.

What this package does add is an audit line per accepted change (audit.log_channel, pseudonymised) and — where LeadHub is installed — a contact-timeline entry. Both are switchable in config/preference-center.php. There is no telemetry and no outbound call of any kind other than the magic-link mail your own mailer sends.

Multi-brand

Brand-aware, per brand, always derived from the door the visitor came through — never from the session. SetBrandFromRouteValue takes it from the subscription token, a magic link carries its brand sealed inside it, and the link-request page asks for it. A page that inherited the brand from whatever the browser last looked at would show one audience's lists to another's. Statamic sites (as distinct from brands) are not used by this package.

Tests

composer test
composer lint                        # Pint, check only
composer analyse                     # PHPStan level 5 with a baseline
DB_DRIVER=mysql vendor/bin/pest      # the identical suite against a real server

Support

Only the latest version is supported, against the Statamic major it targets. Bugs and questions go to GitHub issues; anything that turns out to be a core bug belongs in statamic/cms. Security reports do not go in a public issue — see SECURITY.md.

Upgrading · Changelog · License

UPGRADE.md · CHANGELOG.md · MIT, see LICENSE.