codinglabsau / laravel-notification-subscriptions
Manage user notification subscriptions across multiple notification types and channels
Installs: 1 193
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/codinglabsau/laravel-notification-subscriptions
Requires
- php: ^8.3
- illuminate/contracts: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.9.2
Requires (Dev)
- laravel/pint: ^1.25
- orchestra/testbench: ^9.0|^10
- pestphp/pest: ^2.0
- spatie/laravel-ray: ^1.26
This package is auto-updated.
Last update: 2026-01-29 07:08:32 UTC
README
A Laravel package for managing user notification preferences across multiple channels. Let your users control how they receive notifications (email, in-app, push, Slack) while you maintain sensible defaults and rate limiting.
Features
- Channel-based subscriptions - Users can enable/disable notifications per channel
- Custom channels - Define your own channel enum with any channels you need (mail, database, Slack, Pusher, OneSignal, etc.)
- Per-notification control - Configure which channels each notification type supports
- Smart defaults - New users get sensible defaults; preferences are only stored when changed
- Rate limiting - Prevent notification spam with configurable per-channel rate limits
- Mandatory channels - Per-notification channels that users can't opt out of
- Simple API -
$user->getNotificationPreferences()and$user->updateNotificationPreferences()for settings UIs
Installation
1. Install via Composer
composer require codinglabsau/laravel-notification-subscriptions
2. Publish and run migrations
php artisan vendor:publish --tag="laravel-notification-subscriptions-migrations"
php artisan migrate
3. Publish configuration (optional)
php artisan vendor:publish --tag="laravel-notification-subscriptions-config"
4. Create your channel enum
Create an enum that implements SubscribableChannel to define your notification channels:
// app/Enums/NotificationChannel.php namespace App\Enums; use Codinglabs\NotificationSubscriptions\Contracts\SubscribableChannel; enum NotificationChannel: string implements SubscribableChannel { case DATABASE = 'database'; case MAIL = 'mail'; case SLACK = 'slack'; public function driver(): string { return $this->value; } public function label(): string { return match ($this) { self::DATABASE => 'In-App', self::MAIL => 'Email', self::SLACK => 'Slack', }; } public function isEnabled(): bool { return true; } public function defaultOn(): bool { return match ($this) { self::SLACK => false, default => true, }; } public function hasRateLimiting(): bool { return match ($this) { self::DATABASE => false, default => true, }; } public function rateLimitDuration(): int { return match ($this) { self::MAIL => 300, // 5 minutes default => 60, }; } }
5. Add trait to your User model
use Codinglabs\NotificationSubscriptions\Concerns\HasNotificationSubscriptions; class User extends Authenticatable { use HasNotificationSubscriptions; // ... }
6. Publish and configure the service provider
php artisan vendor:publish --tag="laravel-notification-subscriptions-provider"
Then register your subscribable notifications in app/Providers/NotificationSubscriptionsServiceProvider.php:
use Codinglabs\NotificationSubscriptions\Facades\NotificationSubscriptions; public function boot(): void { NotificationSubscriptions::register([ \App\Notifications\OrderShippedNotification::class, \App\Notifications\NewMessageNotification::class, ]); // Conditional registration if (config('features.slack_enabled')) { NotificationSubscriptions::register([ \App\Notifications\SlackAlertNotification::class, ]); } }
Don't forget to add this service provider to your bootstrap/providers.php:
return [ // ... App\Providers\NotificationSubscriptionsServiceProvider::class, ];
Basic Usage
Creating a Subscribable Notification
Transform any Laravel notification into a subscribable notification by implementing the SubscribableNotification interface and using the DispatchesNotifications trait:
use App\Enums\NotificationChannel; use Illuminate\Notifications\Notification; use Codinglabs\NotificationSubscriptions\Concerns\DispatchesNotifications; use Codinglabs\NotificationSubscriptions\Contracts\SubscribableNotification; class OrderShippedNotification extends Notification implements SubscribableNotification { use DispatchesNotifications; public function __construct( public Order $order ) {} // Unique identifier for this notification type public static function type(): string { return 'order_shipped'; } // Which channels this notification supports public static function channels(): array { return [NotificationChannel::DATABASE, NotificationChannel::MAIL]; } // Who should receive this notification public function subscribers() { return collect([$this->order->user]); } // Standard Laravel notification methods public function toMail($notifiable) { return (new MailMessage) ->subject('Your order has shipped!') ->line("Order #{$this->order->id} is on its way."); } public function toArray($notifiable) { return [ 'title' => 'Order Shipped', 'message' => "Order #{$this->order->id} has shipped.", 'order_id' => $this->order->id, ]; } }
Dispatching Notifications
Use the static sendToSubscribers() method instead of Laravel's standard notification sending:
// This automatically: // 1. Finds all subscribers // 2. Checks each user's channel preferences // 3. Applies rate limiting // 4. Sends to appropriate channels only OrderShippedNotification::sendToSubscribers($order);
Transactional vs Subscribable Notifications
| Use Case | Method | Behavior |
|---|---|---|
| Transactional (password reset, order confirmation) | Standard Laravel $user->notify() |
Always sends, no filtering |
| Subscribable (messages, updates, marketing) | Notification::sendToSubscribers() |
Respects user preferences |
// Subscribable - respects user preferences OrderShippedNotification::sendToSubscribers($order); // Transactional - always sends (standard Laravel) $user->notify(new PasswordResetNotification());
How It Works
When a notification is dispatched:
- Subscriber lookup - The
subscribers()method determines who should receive the notification - Channel filtering - For each subscriber, the package checks their preferences:
- If they have a stored preference for this notification type, only enabled channels are used
- If no preference exists, channels with
defaultOn() === trueare used
- Rate limiting - If a channel has rate limiting enabled and the notification has a subject, duplicate notifications are throttled
- Delivery - The notification is sent only to the appropriate channels
Channel Enum Reference
Your channel enum must implement SubscribableChannel with these methods:
| Method | Return Type | Description |
|---|---|---|
driver() |
string |
Laravel notification channel driver (e.g., 'database', 'mail', OneSignalChannel::class) |
label() |
string |
Human-readable label for UI (e.g., 'Email', 'Push Notifications') |
isEnabled() |
bool |
Whether this channel is currently available |
defaultOn() |
bool |
Whether new users have this channel enabled by default |
hasRateLimiting() |
bool |
Whether rate limiting applies to this channel |
rateLimitDuration() |
int |
Rate limit duration in seconds |
Example: Advanced Channel Configuration
use NotificationChannels\OneSignal\OneSignalChannel; enum NotificationChannel: string implements SubscribableChannel { case DATABASE = 'database'; case MAIL = 'mail'; case PUSH = OneSignalChannel::class; case SLACK = 'slack'; public function driver(): string { return $this->value; } public function label(): string { return match ($this) { self::DATABASE => 'In-App', self::MAIL => 'Email', self::PUSH => 'Push Notifications', self::SLACK => 'Slack', }; } public function isEnabled(): bool { return match ($this) { self::PUSH => config('services.onesignal.app_id') !== null, self::SLACK => config('services.slack.webhook_url') !== null, default => true, }; } public function defaultOn(): bool { return match ($this) { self::PUSH, self::SLACK => false, default => true, }; } public function hasRateLimiting(): bool { return match ($this) { self::DATABASE => false, // In-app doesn't need rate limiting default => true, }; } public function rateLimitDuration(): int { return match ($this) { self::MAIL => 300, // 5 minutes for emails self::PUSH => 60, // 1 minute for push self::SLACK => 60, // 1 minute for Slack default => config('notification-subscriptions.default_rate_limit_duration', 60), }; } }
Rate Limiting
Rate limiting prevents notification spam when the same notification could be triggered multiple times in quick succession.
How Rate Limiting Works
Rate limits are applied per combination of:
- Notification type (e.g.,
order_shipped) - Channel (e.g.,
mail) - Subject (the model that triggered the notification)
- Recipient (the user receiving the notification)
Subject Method
For rate limiting to work, your notification must return a subject:
public function subject(): ?Model { return $this->order; // The model triggering the notification }
If subject() returns null, rate limiting is skipped for that notification.
Mandatory Channels
Some notifications must always be sent via certain channels regardless of user preferences. For example, a support ticket reply might always need an email notification, even if the user has opted out of email for other notifications.
Defining Mandatory Channels
Override mandatoryChannels() in your notification class to specify channels that cannot be unsubscribed from:
class TicketReplyNotification extends Notification implements SubscribableNotification { use DispatchesNotifications; public static function type(): string { return 'ticket_reply'; } public static function channels(): array { return [NotificationChannel::DATABASE, NotificationChannel::MAIL, NotificationChannel::PUSH]; } // Mail is mandatory — users cannot unsubscribe from it public static function mandatoryChannels(): array { return [NotificationChannel::MAIL]; } // ... }
By default, mandatoryChannels() returns an empty array, meaning all channels are optional.
How Mandatory Channels Work
Mandatory channels are enforced at three levels:
-
shouldSend()defense-in-depth — When dispatching a notification, mandatory channels always returntrueinshouldSend(), even if the user's subscription record excludes them. This ensures delivery even with stale subscription data. -
Validation re-injection — The
ValidatesNotificationPreferencestrait automatically re-injects mandatory channels into form requests duringprepareForValidation(), so they can never be removed by user input. -
NotificationPreferencesDTO — ThegetNotificationPreferences()method populates amandatoryproperty on the DTO, mapping each notification type to its mandatory channel values. This allows your UI to render mandatory channels as disabled/locked checkboxes.
Using Mandatory Data in the UI
The NotificationPreferences DTO includes a mandatory property:
NotificationPreferences {
types: [...],
values: [...],
mandatory: [
'ticket_reply' => ['mail'],
],
}
Use this in your frontend to disable checkboxes for mandatory channels:
<input type="checkbox" :value="value" v-model="form[type]" :disabled="mandatory[type]?.includes(value)" />
Building a Settings UI
The package provides a simple API for building notification preference UIs. The HasNotificationSubscriptions trait adds two methods to your User model:
getNotificationPreferences()- Returns a DTO withtypesandvaluesfor the UIupdateNotificationPreferences(array $preferences)- Updates preferences in the database
Controller Setup
use Codinglabs\NotificationSubscriptions\Concerns\ValidatesNotificationPreferences; class NotificationSettingsController extends Controller { public function edit() { return view('settings.notifications', [ 'preferences' => auth()->user()->getNotificationPreferences(), ]); } public function update(UpdateNotificationSettingsRequest $request) { auth()->user()->updateNotificationPreferences($request->validated()); return redirect()->back()->with('success', 'Preferences saved.'); } } // Form request - just add the trait, no configuration needed class UpdateNotificationSettingsRequest extends FormRequest { use ValidatesNotificationPreferences; }
The ValidatesNotificationPreferences trait:
- Generates validation rules for each registered notification type
- Automatically re-injects mandatory channels (users can't opt out of them)
The NotificationPreferences DTO
The getNotificationPreferences() method returns a NotificationPreferences object with three properties:
NotificationPreferences {
// Channel options for the UI (enabled channels only)
types: [
'order_shipped' => ['database' => 'In-App', 'mail' => 'Email', 'slack' => 'Slack'],
'new_message' => ['mail' => 'Email'],
],
// User's current selections (or defaults)
values: [
'order_shipped' => ['database', 'mail'],
'new_message' => ['mail', 'slack'],
],
// Channels that cannot be unsubscribed from
mandatory: [
'order_shipped' => ['mail'],
],
}
Example Blade Template
<form method="POST" action="{{ route('settings.notifications.update') }}"> @csrf @method('PUT') @foreach($preferences->types as $notificationType => $channels) <div class="notification-group"> <h3>{{ Str::title(str_replace('_', ' ', $notificationType)) }}</h3> @foreach($channels as $channel => $label) <label> <input type="checkbox" name="{{ $notificationType }}[]" value="{{ $channel }}" @checked(in_array($channel, $preferences->values[$notificationType] ?? [])) /> {{ $label }} </label> @endforeach </div> @endforeach <button type="submit">Save Preferences</button> </form>
Inertia/Vue Example
<script setup> import { useForm } from '@inertiajs/vue3'; const props = defineProps({ preferences: Object }); // Initialize form directly from values const form = useForm({ ...props.preferences.values }); </script> <template> <form @submit.prevent="form.put('/settings/notifications')"> <div v-for="(channels, type) in preferences.types" :key="type"> <h3>{{ type }}</h3> <label v-for="(label, value) in channels" :key="value"> <input type="checkbox" :value="value" v-model="form[type]" /> {{ label }} </label> </div> <button type="submit">Save</button> </form> </template>
Advanced Usage
Custom Notification Labels
Add metadata methods to your notifications for richer UIs:
class OrderShippedNotification extends Notification implements SubscribableNotification { use DispatchesNotifications; public static function type(): string { return 'order_shipped'; } // Custom methods for UI (not part of interface) public static function label(): string { return 'Order Shipping Updates'; } public static function description(): string { return 'Get notified when your orders ship and are delivered.'; } // ... }
Before Send Hook
Execute code before any notification is sent:
class OrderShippedNotification extends Notification implements SubscribableNotification { use DispatchesNotifications; public static function beforeSend($notification): void { // Log, track analytics, modify notification, etc. Log::info('Sending order shipped notification', [ 'order_id' => $notification->order->id, ]); } // ... }
Custom Subscription Model
Extend the base model if you need additional functionality:
// app/Models/NotificationSubscription.php use Codinglabs\NotificationSubscriptions\Models\NotificationSubscription as BaseModel; class NotificationSubscription extends BaseModel { // Add custom methods, scopes, etc. } // config/notification-subscriptions.php 'subscription_model' => App\Models\NotificationSubscription::class,
Database Schema
The package creates a notification_subscriptions table:
| Column | Type | Description |
|---|---|---|
| id | bigint | Primary key |
| user_id | bigint | Foreign key to users table |
| type | string | Notification type identifier |
| channels | json | Array of enabled channel names |
| created_at | timestamp | Creation timestamp |
| updated_at | timestamp | Last update timestamp |
A unique constraint ensures one subscription record per user/type combination.
Testing
composer test
Credits
License
The MIT License (MIT). Please see License File for more information.