Preferences
Each user decides per type, per category and per channel. By default muting stops the email only — the inbox keeps the item — and mandatory types cannot be muted at all:
use RoundlyConsulting\Notifications\Enums\Channel;
$prefs = Notifications::preferences($user); // or $user->notificationPreferences()
$prefs->mute('comment.posted'); // mail only by default — the inbox keeps the item
$prefs->mute('comment.posted', Channel::Inbox); // one channel (honoured only with inbox muting on)
$prefs->unmute('comment.posted'); // back to the category and type default
$prefs->set('request.awaiting_approval', Channel::Mail, false);
$prefs->muteCategory('comments');
$prefs->unmuteCategory('comments');
$prefs->reset(); // every type and category override forgotten
$prefs->mute('security.alert'); // MandatoryNotificationException
$prefs->muteCategory('comment'); // UnknownNotificationCategoryException — no type uses itThe effective channel
For type T, channel C and user U: C not in T.channels() → off; T.mandatory() → on; C is the inbox and notifications.preferences.inbox_mutable is off → on; else the type override → the category override → T.enabledByDefault(C). Only overrides are stored, so a changed type default reaches every user who never touched it.
- A category mute does not silence a mandatory type in it; a stored mute of a type that later became mandatory is ignored.
- A category no registered type uses is refused on every write with UnknownNotificationCategoryException — a typo never mutes nothing silently.
- Every write drops stored overrides of unregistered types and unused categories.
Reading for a settings screen
$prefs->matrix(); // list<PreferenceRow{type: TypeDefinition, channels: list<ChannelPreference>}>
$prefs->enabled('comment.posted', Channel::Mail); // bool
$prefs->muted('comment.posted'); // mail off?
$prefs->overview(); // PreferencesOverview: rows, digest, quietHours, timezone
$prefs->digestPreference(); // DigestPreferenceData
$prefs->quietHoursPreference(); // ?QuietHours
$prefs->resolvedTimezone(); // 'Europe/Bratislava'ChannelPreference carries channel, enabled, default and locked — locked for mandatory types, and for the inbox unless inbox muting is on.
Several changes at once
update() applies a whole settings screen all or nothing — what PATCH /preferences uses. Only what the update names changes:
use RoundlyConsulting\Notifications\DataTransferObjects\DigestPreferenceData;
use RoundlyConsulting\Notifications\DataTransferObjects\PreferencesUpdate;
use RoundlyConsulting\Notifications\DataTransferObjects\QuietHours;
use RoundlyConsulting\Notifications\Enums\DigestFrequency;
Notifications::preferences($user)->update(new PreferencesUpdate(
channels: ['comment.posted' => ['mail' => false]], // type → channel → on/off
categories: ['contracts' => ['mail' => false]], // false mutes, true clears the mute
digest: new DigestPreferenceData(DigestFrequency::Daily, '08:00'),
setsQuietHours: true, quietHours: new QuietHours('22:00', '07:00'), // setsQuietHours + null clears
setsTimezone: true, timezone: 'Europe/Bratislava', // setsTimezone + null clears
));Everything is validated first — an unregistered type, a channel other than inbox or mail, a mandatory type switched off, an unused category, a bad time or timezone throws before anything is written. Then every write runs under one preference lock in one transaction, so a failure half way rolls all of them back.
Storage
Four options-for-laravel options, registered by the package, each with its own stored key. The owner is the user, so options.key_type must equal notifications.key_type. Reads and writes bypass options’ authorization — the package API is the boundary, and the HTTP layer only ever acts on the authenticated user:
| Option | Key | Value |
|---|---|---|
Options\ChannelPreferences | notifications.channels | {"types": {"<key>": {"mail": false}}, "categories": {"<cat>": {"mail": false}}} |
Options\DigestPreference | notifications.digest | {"frequency": "daily", "time": "07:30"} |
Options\QuietHoursPreference | notifications.quiet_hours | {"start": "22:00", "end": "07:00"} |
Options\TimezonePreference | notifications.timezone | an IANA id |
Concurrency and events
A write is a read-modify-write of the JSON map. It takes a cache lock (notifications.preferences.lock_store, held lock_seconds) around the whole transaction and releases it after the commit — inside a host transaction, after the host’s commit — so two concurrent mutes of different types both survive. The lock store must be shared across servers; the doctor warns about array and file. Every write fires NotificationPreferenceChanged after the commit, and digest, quiet-hours and timezone changes re-time the user’s held and parked mail in the same transaction.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.