| name | Laravel Notification Preferences |
| description | Conventions and APIs for the offload-project/laravel-notification-preferences package — per-user notification preferences, automatic channel filtering, forced channels, Gate-based authorization, and email unsubscribe links. |
| compatible_agents | ["Claude Code","Cursor"] |
| tags | ["laravel","php","notifications","eloquent","preferences","unsubscribe","authorization"] |
Context
offload-project/laravel-notification-preferences is a Laravel 11/12/13 package (PHP 8.3+) for managing per-user notification preferences across multiple channels. It ships:
- A
NotificationPreference Eloquent model storing one row per (user, notification_type, channel).
- A
HasNotificationPreferences trait for the User model (get/set, bulk, unsubscribe URL helpers).
- A
ChecksNotificationPreferences trait for opt-in self-filtering inside a notification's via().
- A
HasUnsubscribeUrl trait for generating signed URLs and adding List-Unsubscribe headers (RFC 8058).
- A
NotificationChannelFilter listener on NotificationSending that automatically blocks channels per stored preferences, forced channels, and Gate authorization.
- An
AuthorizesNotification interface for tying notifications to a Gate ability.
- A
NotificationPreferences facade and NotificationPreferenceManagerInterface for static or injected access.
- Events:
NotificationPreferenceChanged, NotificationAuthorizationDenied.
- Structured exceptions:
InvalidNotificationTypeException, InvalidChannelException, InvalidGroupException.
- A signed unsubscribe controller mounted at
notification-preferences/unsubscribe and notification-preferences/resubscribe.
Apply this skill when working in a Laravel app that has offload-project/laravel-notification-preferences in composer.json, or when the user asks for help with NotificationPreferences, HasNotificationPreferences, the preferences UI/table, the unsubscribe flow, or AuthorizesNotification.
Rules
Trait usage
- Apply
HasNotificationPreferences to the User model (the one declared in notification-preferences.user_model). Do not apply it to anything other than the configured user model — the package uses it as the notifiable.
- To opt a single notification into self-filtering, use the
ChecksNotificationPreferences trait and return $this->allowedChannels($notifiable, [...]) from via(). This is only needed when you want the notification to manage its own channel list — the automatic filter handles preferences for any registered notification without changes to its code.
- To add unsubscribe links/headers to a mail notification, use the
HasUnsubscribeUrl trait and call $this->withUnsubscribeHeaders($mailMessage, $notifiable) or $this->getUnsubscribeUrl($notifiable).
Configuration
- Every notification you want preference-controlled must be registered under
notification-preferences.notifications with at minimum a group and label. Sending an unregistered notification is allowed — the channel filter just lets it through without preference checks.
- Every channel referenced anywhere (notification
default_channels, force_channels, preference writes) must exist in notification-preferences.channels. Writing a preference for an unregistered channel throws InvalidChannelException.
- Group keys referenced in
notifications[].group or in setGroupPreferences() must exist in notification-preferences.groups. Bulk group writes throw InvalidGroupException for an unknown group.
- The
default_preference cascade is global → group → notification (most specific wins). Use the strings 'opt_in' / 'opt_out', not booleans.
Sending notifications
- Just call
$user->notify(new YourNotification(...)) — the NotificationChannelFilter listener intercepts each channel via NotificationSending and blocks any that the user has disabled. Do not manually check getNotificationPreference() before notify(); the listener already does it.
- The filter only runs for
Authenticatable notifiables. Anonymous routes (Notification::route('mail', ...)) bypass preference checks — there is no user identity to match preferences against.
Forced channels
- Use
force_channels in the notification's config entry to make a channel always send regardless of user preference. The user still sees that row in the preferences table with 'forced' => true, but cannot toggle it off. Bulk methods (setChannelPreferences, setGroupPreferences, etc.) automatically skip forced channels.
- Authorization (
ability / AuthorizesNotification) overrides forced channels — a user who fails the Gate check never receives the notification, even on forced channels. This is intentional; do not work around it.
Permission gating
- For notifications you own, implement
AuthorizesNotification and return the Gate ability from notificationAbility(). For third-party notifications, set 'ability' => 'gate-name' in the config entry instead. Prefer the interface when both are an option.
notificationAbility() is static (called from the preferences UI before any instance exists). Do not branch on instance state inside it. For instance-aware authorization, write a Gate closure with ?YourNotification $notification = null and branch on whether $notification was passed — the channel filter passes the notification instance through Gate::allows().
- A failed Gate check blocks dispatch across all channels and hides the notification from
getPreferencesTable(). A NotificationAuthorizationDenied event fires per blocked channel for observability.
Managing preferences
-
Set a single preference via $user->setNotificationPreference($notificationClass, $channel, $enabled) — returns the persisted NotificationPreference model and dispatches NotificationPreferenceChanged.
-
Read a single preference via $user->getNotificationPreference($notificationClass, $channel) — returns bool, honoring the registered default cascade for any unwritten preference.
-
For UI rendering, call $user->getNotificationPreferencesTable() (or NotificationPreferences::getPreferencesTable($user)). This returns a grouped, ordered, label-resolved structure ready to drop into Blade/Inertia/Livewire. Notifications the user fails Gate for are already filtered out.
-
For bulk writes, use:
$user->setChannelPreferences($channel, $enabled) — flip a channel on/off across every notification.
$user->setGroupPreferences($groupKey, $channel, $enabled) — flip a channel on/off across one group.
$user->setNotificationChannelPreferences($notificationClass, $enabled) — flip every channel for one notification type.
$user->resetNotificationPreferences() — delete all the user's stored preferences (the defaults take over).
Each bulk method returns the integer count actually updated and automatically skips forced channels.
Deprecated methods (avoid in new code)
- Do not use the deprecated bulk-write aliases on
HasNotificationPreferences — they will be removed in v2.0:
setGroupChannelPreference() → use setGroupPreferences().
setChannelPreferenceForAll() → use setChannelPreferences().
setAllChannelsForNotification() → use setNotificationChannelPreferences().
Unsubscribe URLs
- Generate unsubscribe/resubscribe URLs via:
- The
HasUnsubscribeUrl trait inside a notification ($this->getUnsubscribeUrl($notifiable), $this->getResubscribeUrl($notifiable)).
- The user model (
$user->notificationUnsubscribeUrl($notificationClass)).
- The facade (
NotificationPreferences::unsubscribeUrl($user, $notificationClass, $channel = 'mail')).
- For mail notifications, call
$this->withUnsubscribeHeaders($mailMessage, $notifiable) to add List-Unsubscribe and List-Unsubscribe-Post headers — this is what unlocks the native unsubscribe button in Gmail/Apple Mail. Then expose the link in the body too (e.g. via ->action('Unsubscribe', $this->getUnsubscribeUrl($notifiable))).
- Unsubscribe URLs are signed. Do not disable the
signed middleware on the package routes, and do not remove the withoutMiddleware(VerifyCsrfToken::class) from the POST route — RFC 8058 one-click POST clients won't send a CSRF token.
- The
url_ttl config defaults to null (permanent). Set it only when you want links to expire (e.g. 60 * 24 * 30 for a month).
Cache
- The manager memoizes both the package config and per-user preferences. After making out-of-band changes (raw DB writes, factory seeding in tests, config swaps inside a test), call
NotificationPreferences::clearUserCache($userId) and/or clearConfigCache(). Normal set*Preferences() calls invalidate user cache automatically.
Events
- Listen for
NotificationPreferenceChanged to audit, sync to a CDP, or warm a cache. The event exposes $preference (the NotificationPreference model), $user (the notifiable), and $wasCreated (true on insert, false on update).
- Listen for
NotificationAuthorizationDenied to observe Gate-blocked sends. The event exposes $notifiable, $notification, $channel, and $ability — useful for surfacing "why didn't this user receive X?" in support tooling.
Manager / facade
- Prefer injecting
NotificationPreferenceManagerInterface in controllers/services for testability. Use the NotificationPreferences facade only for one-off calls, Blade snippets, or in code where DI is impractical. Both resolve to the same singleton.
- The
NotificationPreference model is final. To extend behavior, listen to events or wrap manager calls — do not try to subclass it.
Examples
Basic setup
use OffloadProject\NotificationPreferences\Concerns\HasNotificationPreferences;
class User extends Authenticatable
{
use HasNotificationPreferences;
}
return [
'default_preference' => 'opt_in',
'channels' => [
'mail' => ['label' => 'Email', 'enabled' => true],
'database' => ['label' => 'In-App', 'enabled' => true],
'broadcast'=> ['label' => 'Push', 'enabled' => true],
],
'groups' => [
'system' => ['label' => 'System', 'default_preference' => 'opt_in', 'order' => 1],
'marketing' => ['label' => 'Marketing', 'default_preference' => 'opt_out', 'order' => 2],
],
'notifications' => [
\App\Notifications\OrderShipped::class => [
'group' => 'system',
'label' => 'Order Shipped',
'description' => 'When your order ships',
'default_channels' => ['mail', 'database'],
'order' => 1,
],
\App\Notifications\WeeklyDigest::class => [
'group' => 'marketing',
'label' => 'Weekly Digest',
'order' => 1,
],
],
'cache_ttl' => 1440,
'user_model' => \App\Models\User::class,
];
Sending — automatic filtering
$user->notify(new \App\Notifications\OrderShipped($order));
Self-filtering inside via()
use OffloadProject\NotificationPreferences\Concerns\ChecksNotificationPreferences;
class OrderShipped extends Notification
{
use ChecksNotificationPreferences;
public function via($notifiable): array
{
return $this->allowedChannels($notifiable, ['mail', 'database', 'broadcast']);
}
}
Forced channel
'notifications' => [
\App\Notifications\SecurityAlert::class => [
'group' => 'security',
'label' => 'Security Alerts',
'force_channels' => ['mail', 'database'],
],
],
Gate-controlled notification
use OffloadProject\NotificationPreferences\Contracts\AuthorizesNotification;
class OrderShipped extends Notification implements AuthorizesNotification
{
public function __construct(public Order $order) {}
public static function notificationAbility(): string
{
return 'view-orders';
}
}
Gate::define('view-orders', function (User $user, ?OrderShipped $notification = null) {
if ($notification === null) {
return $user->can('view-orders');
}
return $user->can('view', $notification->order);
});
Unsubscribe link + List-Unsubscribe header
use Illuminate\Notifications\Messages\MailMessage;
use OffloadProject\NotificationPreferences\Concerns\HasUnsubscribeUrl;
class OrderShipped extends Notification
{
use HasUnsubscribeUrl;
public function toMail($notifiable): MailMessage
{
return $this->withUnsubscribeHeaders(
(new MailMessage)
->line('Your order has shipped!')
->action('Unsubscribe', $this->getUnsubscribeUrl($notifiable)),
$notifiable,
);
}
}
Bulk operations
$user->setChannelPreferences('mail', false);
$user->setGroupPreferences('marketing', 'mail', false);
$user->setNotificationChannelPreferences(WeeklyDigest::class, false);
$user->resetNotificationPreferences();
UI table
return Inertia::render('Settings/Notifications', [
'preferences' => $request->user()->getNotificationPreferencesTable(),
]);
The returned shape:
[
[
'group' => 'system',
'label' => 'System Notifications',
'description' => 'Important system updates',
'notifications' => [
[
'type' => 'App\Notifications\OrderShipped',
'label' => 'Order Shipped',
'description' => 'When your order ships',
'channels' => [
'mail' => ['enabled' => true, 'forced' => false],
'database' => ['enabled' => true, 'forced' => false],
],
],
],
],
]
Listening for changes
use Illuminate\Support\Facades\Event;
use OffloadProject\NotificationPreferences\Events\NotificationPreferenceChanged;
use OffloadProject\NotificationPreferences\Events\NotificationAuthorizationDenied;
Event::listen(NotificationPreferenceChanged::class, function (NotificationPreferenceChanged $event): void {
Log::info('preference changed', [
'user' => $event->user->getAuthIdentifier(),
'type' => $event->preference->notification_type,
'channel' => $event->preference->channel,
'enabled' => $event->preference->enabled,
'created' => $event->wasCreated,
]);
});
Event::listen(NotificationAuthorizationDenied::class, function (NotificationAuthorizationDenied $event): void {
Log::warning('notification blocked by gate', [
'ability' => $event->ability,
'channel' => $event->channel,
'class' => $event->notification::class,
]);
});
Controller using the manager interface
use OffloadProject\NotificationPreferences\Contracts\NotificationPreferenceManagerInterface;
class NotificationPreferenceController
{
public function __construct(
private NotificationPreferenceManagerInterface $manager,
) {}
public function update(Request $request)
{
$data = $request->validate([
'notification_type' => ['required', 'string'],
'channel' => ['required', 'string'],
'enabled' => ['required', 'boolean'],
]);
$this->manager->setPreference(
$request->user(),
$data['notification_type'],
$data['channel'],
$data['enabled'],
);
return back();
}
}
Anti-patterns
- ❌ Calling
NotificationPreference::create([...]) directly — bypasses the NotificationPreferenceChanged event and cache invalidation. Always go through setPreference() / $user->setNotificationPreference(...).
- ❌ Wrapping
$user->notify(...) in a manual getNotificationPreference() check. The channel filter already does this for every registered notification — double-checking is redundant and gets the cascade wrong when only one channel should be skipped.
- ❌ Subclassing
NotificationPreference. The model is final; listen to NotificationPreferenceChanged or wrap the manager instead.
- ❌ Using
setGroupChannelPreference(), setChannelPreferenceForAll(), or setAllChannelsForNotification(). Deprecated — use the set*Preferences() aliases.
- ❌ Disabling the
signed middleware on the unsubscribe routes, or removing the VerifyCsrfToken exclusion on the POST route. The first opens the endpoint to forgery; the second breaks RFC 8058 one-click unsubscribe.
- ❌ Branching on
$this state inside AuthorizesNotification::notificationAbility(). It is called statically from the preferences UI; instance state is null there. Move payload-aware checks into the Gate closure, which receives the notification instance at dispatch time.
- ❌ Calling Gate-controlled notifications "force-channel safe". Authorization beats
force_channels — a failing Gate check blocks every channel, by design.
- ❌ Using boolean
default_preference values. The cascade expects the strings 'opt_in' / 'opt_out' (also exposed as the DefaultPreference enum).
- ❌ Editing files inside
vendor/offload-project/laravel-notification-preferences. All extension points (channels, groups, defaults, abilities, unsubscribe routes/middleware/redirect) are exposed via config/notification-preferences.php.
References