goldnead / statamic-notifications
Persisted, brand-scoped notifications for Statamic: types, per-type preferences, in-app, immediate mail and deduplicated digests.
Package info
github.com/goldnead/statamic-notifications
Type:statamic-addon
pkg:composer/goldnead/statamic-notifications
Requires
- php: ^8.3
- goldnead/statamic-brand-context: ^1.0
- goldnead/statamic-identity-contracts: ^1.0
- goldnead/statamic-suppression: ^1.0
- laravel/framework: ^12.0|^13.0
- statamic/cms: ^6.0
Requires (Dev)
- goldnead/statamic-leadhub: ^1.4
- larastan/larastan: ^3.10
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^2.0|^3.0|^4.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0|^4.0
Suggests
- goldnead/statamic-leadhub: Enables the bundled CRM source (new leads, assignments, follow-up digests).
README
Statamic Notifications
Persisted, brand-scoped notifications: types, per-type preferences, in-app, immediate mail, and digests that do not repeat themselves.
Why it exists
This pattern gets reinvented. In this family it happened three times: a runtime aggregation over community tables, a CRM addon's own mail notifier with its own digest command, and Laravel's built-in system used purely as a mail sender. None of them could be reused by the next domain that needed notifying.
Requirements
| Statamic | 6.0+ |
| Laravel | 12.40+ or 13 |
| PHP | 8.3+ |
| Database | MySQL 8, MariaDB 10.6 or SQLite. The tables are Eloquent, not flat files. |
| Queue | not required. Mail is sent inline; digests run from your scheduler. |
Also requires goldnead/statamic-brand-context,
goldnead/statamic-identity-contracts and goldnead/statamic-suppression. All
three behave inertly in a single-brand application.
Installation
composer require goldnead/statamic-notifications php artisan migrate php artisan vendor:publish --tag=notifications-config
The Control Panel screen appears under Tools → Notifications for anyone
holding the view notifications permission.
Publish tags
| Tag | What it copies |
|---|---|
notifications-config |
config/notifications.php |
notifications-translations |
lang/vendor/notifications (de, en) |
notifications-migrations |
the four migrations, if you want to own them |
notifications-views |
resources/views/vendor/notifications — mail templates and the two CP views |
Usage
use Goldnead\Notifications\Facades\Notifications; Notifications::notify($user, 'community.mention', [ 'message' => 'Bea hat dich erwähnt.', 'link' => '/account/community/posts/'.$post->id, 'dedupe_key' => 'mention:'.$mention->id, ]); Notifications::unreadCount($user); Notifications::markRead($item);
That is the whole minimum: a recipient, a type handle, and a payload. Everything below is the detail — types, preferences, channels and digests.
Notifying
use Goldnead\Notifications\Facades\Notifications; Notifications::notify($user, 'community.mention', [ 'actor' => $author, // anything IdentityContext can resolve 'subject' => $post, // any Eloquent model 'message' => 'Bea hat dich erwähnt.', 'link' => '/account/community/posts/'.$post->id, 'dedupe_key' => 'mention:'.$mention->id, ]); Notifications::notifyMany($subscribers, 'lms.lesson_published', [ 'dedupe_key' => 'lesson:'.$lesson->id, // scoped per recipient automatically ]);
A recipient must be identifiable. Notifying an anonymous visitor returns null — there would be no way to ever show it to them again.
Notifying is idempotent when you pass a dedupe_key: the same fact reaching
two producers yields one notification. Notifying never breaks the caller: a
mail transport error must not roll back the comment that caused it.
Types
A type says what a notification is called, which channels it uses by default, and how it renders:
Notifications::registerType('community.mention', function ($type) { $type->label('Erwähnung') ->defaultChannels(['in_app', 'mail']) ->renderUsing(fn ($item) => [ 'message' => $item->actor_name.' hat dich erwähnt.', 'link' => '/account/community/posts/'.$item->subject_id, ]); });
Rendering is a callback, not a template: the host owns the wording and the URL structure. The addon never hardcodes a sentence or a route — that is exactly what made the system it replaces impossible to extract.
Unregistered types still deliver (in-app, using whatever the producer passed), so a missing registration never silently swallows someone's notification.
Register types in a service provider, not in the calling code. The registry
lives per process. A type registered ad hoc — inside a controller, a console
one-off — is unknown to the scheduled digest process, falls back to the in_app
default and is silently skipped there. The notification exists and is never
summarised. That skipping is deliberate (it is what stops an immediate e-mail
being repeated days later), which is precisely why the registration has to be
global.
->required() makes a type ignore preferences. For account security and legal
notices only.
Preferences
Per type × channel, stored only as deviations. Absence means "use the type's default", so changing a default actually reaches everyone who never expressed an opinion.
$preferences = app(PreferenceResolver::class); $preferences->set($user, 'community.mention', 'mail', false); $preferences->matrixFor($user); // for a preference centre
Note the asymmetry: the persisted row is always written, because it is the
record that this happened. Preferences govern how someone is reached — turning
off in_app silences the realtime nudge, it does not erase history.
Channels
| Channel | Behaviour |
|---|---|
in_app |
the row itself, plus an optional realtime nudge |
mail |
one e-mail per notification, rendered through the type |
digest |
no-op at notify time; the item waits for the next digest run |
Register your own with Notifications::registerChannel().
Digests
php artisan notifications:send-digests --frequency=weekly [--dry-run] [--now=…]
Two things this does that the system it replaces did not:
- A window. Daily covers 24 hours, weekly covers 7 days. The old digest took "everything currently unread", which is unbounded and unrelated to the period being reported.
- A record of the send.
notification_digest_runsis unique on (brand, recipient, frequency, window start), and every collected item is stampeddigested_at. Without this an unread item went out again every week for as long as it stayed unread.
Scheduling is left to the host — register the command in your own scheduler so the send window matches your audience.
Checking the uniqueness constraints
php artisan notifications:uniqueness-integrity [--repair]
php artisan migrate reporting success means the migrations ran. It does not
mean the constraints they were supposed to leave behind are in place, and it
says nothing at all about the rows. This reads the indexes that are on
notification_preferences and notification_digest_runs right now, and the
rows that are in them, and says plainly whether one recipient still means one
row per key. It changes nothing.
You will be pointed at it by migrate itself. Installs created before 1.0.4
could hold duplicate rows for contact recipients, because the unique of the day
led with user_id and no engine constrains a NULL. Where those rows exist the
migration stops and names them rather than choosing between them: which of two
preferences is the one a person currently holds is not a decision a schema
change gets to make. Delete the rows that are not the ones to keep, then run
migrate again. --repair rebuilds the index alone once nothing is in the way,
and refuses while anything is.
Digest sources
Other addons contribute things nobody was notified about:
Notifications::registerSource('community', CommunityDigestSource::class);
A source answers "what should this person also see for this window?" — open follow-ups, upcoming events. A failing source is reported and skipped: one addon's broken query must not silence everybody's weekly mail.
A LeadHub source ships bundled and attaches only when that addon is installed.
Realtime
Off by default. When enabled, a content-free refresh signal broadcasts on
users.{id}; the client re-fetches through the normal authorised endpoint, so a
socket subscriber can never see more than the API would have given them.
'realtime' => ['enabled' => true, 'channel_prefix' => 'users'],
Laravel interop
This addon does not build on Laravel's notifications table. That schema has
no brand column (isolation would have to hide inside the JSON payload — exactly
what brand-context exists to prevent), no dedupe key, and identifies people by
notifiable_type/id rather than by the identity the rest of the platform shares.
The table here is notification_items, so enabling Laravel's database channel
later still works.
Existing $user->notify() call sites route in through a channel:
public function via($notifiable): array { return ['notifications']; } public function toNotifications($notifiable): array { return ['type' => 'crm.lead_assigned', 'message' => '…', 'link' => '…']; }
Control Panel
Read-only inspector at Tools → Notifications. It is Statamic's own listing component, so it behaves like the Entries screen: search, sortable columns, saved views, column customisation, pagination, and three filters — type, read state, and recipient (which matches on user id, e-mail or contact uuid, because support knows the person and not the column). Open a row to see its message, link, actor, dedupe key, payload, and read and digest state.
It answers "did this person get it?", which is the question support actually asks. Nothing on the screen writes.
Permissions: view notifications, manage notification digests. Both CP routes
authorise server-side; a user without the permission is redirected out of the
screen. Set notifications.cp.enabled to false to remove it entirely.
Configuration
Every key in config/notifications.php, with its default:
| Key | Default | Effect when wrong |
|---|---|---|
enabled |
true |
Off means notify() writes nothing and sends nothing. |
channels |
in_app, mail, digest |
A handle removed here can no longer be named by a type or a preference; existing preferences referencing it are ignored. |
digest.default_frequency |
weekly |
Applies to anyone who never chose one. A value your scheduler never calls means those people get no digest at all. |
realtime.enabled |
false |
On without a working broadcaster throws on every notify. |
realtime.channel_prefix |
users |
Must match what your client subscribes to, or the nudge never arrives. |
list_limit |
30 |
Cap for list()/unreadCount() in your own front end. Not used by the CP. |
cp.enabled |
true |
Off removes the inspector, its nav item and its routes. |
sources.leadhub |
true |
Off keeps the bundled LeadHub digest source out even when the addon is installed. |
preferences_url |
null |
Null means digest mails print no link to a preference centre. The addon ships no such page; the host owns it. |
Multi-site
Notifications are not scoped per Statamic site. They are scoped per brand
through goldnead/statamic-brand-context: every row carries a brand_id, and a
global scope on the model means an operator in brand A cannot read brand B's
rows even by guessing an id. In a single-brand installation — which is what a
plain multi-site Statamic is — everything lands in one brand and the scoping is
invisible.
Not in v1
Webhook and push channels, quiet hours, timezone-aware send windows, frequency caps, notification templates. All of them need a scheduler with timezone logic, and without real operational data their design would be guessed rather than derived.
Tests
composer install && vendor/bin/pest
The Integration suite exercises the bundled LeadHub source against the real addon and skips itself when it is not installed.
The default run uses in-memory SQLite. The same suite runs against a real MySQL server too, and CI runs it that way on every push — it is a job, not a release-day ritual:
vendor/bin/pest -c phpunit.mysql.xml
SQLite is not a substitute for it. It has no index length limit, no fixed column
widths and no per-character byte cost, so a schema that MySQL refuses outright
can pass a fully green SQLite run — which is how v1.0.4's defect reached
production. tests/Unit/IndexKeyLengthTest.php closes that particular gap
without needing a server: it compiles the migrations through Laravel's MySQL
grammar and measures every index against InnoDB's 3072-byte limit.
Support · Changelog · License
Issues and questions: https://github.com/goldnead/statamic-notifications/issues. Best effort, no response-time promise. Report anything security-relevant privately to info@adriangoldner.com rather than in a public issue.
Release notes: CHANGELOG.md.
MIT.