NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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 it

The 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:

OptionKeyValue
Options\ChannelPreferencesnotifications.channels{"types": {"<key>": {"mail": false}}, "categories": {"<cat>": {"mail": false}}}
Options\DigestPreferencenotifications.digest{"frequency": "daily", "time": "07:30"}
Options\QuietHoursPreferencenotifications.quiet_hours{"start": "22:00", "end": "07:00"}
Options\TimezonePreferencenotifications.timezonean 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 crypto

By 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.