lacodix / laravel-notification-subscriptions
Notification subscription management.
Package info
github.com/lacodix/laravel-notification-subscriptions
pkg:composer/lacodix/laravel-notification-subscriptions
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0|^13.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- larastan/larastan: ^3.0
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpunit/phpunit: ^11.5.50
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Manage per-recipient notification preferences in Laravel: subscribe or unsubscribe by channel, require opt-in, and scope preferences to another Eloquent model. The package filters Laravel's normal notification sending flow; it does not provide a settings UI.
A fork of liran-co/laravel-notification-subscriptions,
originally authored by Liran Cohen and maintained by Lacodix.
Original author and MIT attribution are retained. The package name is
lacodix/laravel-notification-subscriptions, and its public namespace is
Lacodix\LaravelNotificationSubscriptions.
Requirements and installation
Requires PHP ^8.2 (>=8.2 and <9) and Laravel ^11.0|^12.0|^13.0.
Laravel 13 requires PHP >=8.3. Laravel 11 compatibility does not imply current
security support; use a maintained Laravel version for production. Older
environments need a compatible prior package version.
composer require lacodix/laravel-notification-subscriptions php artisan migrate
Laravel auto-discovers the package provider. The migration creates the
notification_subscriptions table. To optionally publish
config/notification-subscriptions.php, select the provider explicitly:
php artisan vendor:publish --provider='Lacodix\LaravelNotificationSubscriptions\NotificationSubscriptionsServiceProvider'
Enable preferences on your user model
Add both Laravel's Notifiable and the package's HasNotificationSubscriptions
traits to your Eloquent notifiable model. Keep your existing model configuration;
this is a minimal example:
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Lacodix\LaravelNotificationSubscriptions\Traits\HasNotificationSubscriptions; class User extends Authenticatable { use Notifiable; use HasNotificationSubscriptions; }
Notifiables without HasNotificationSubscriptions are not filtered by this
package. Configure your delivery channels using Laravel's
notification documentation.
Subscribe, unsubscribe and reset
Pass the notification's exact class name. Examples below use your application's
App\Notifications\InvoicePaid; a complete illustrative implementation appears in
Model scoping. Lookup IDs are examples for existing application
records.
use App\Models\User; use App\Notifications\InvoicePaid; $user = User::findOrFail(1); $user->unsubscribe(InvoicePaid::class); // Global wildcard: all channels. $user->subscribe(InvoicePaid::class, 'mail'); // Explicit mail exception. $user->unsubscribe(InvoicePaid::class, 'mail'); // Change only the mail rule. $user->subscribe(InvoicePaid::class); // Change the wildcard rule.
An exact channel rule takes priority over '*'. Changing a wildcard does not
remove channel-specific rules: in this example, the final wildcard subscription
still leaves mail unsubscribed. Subscribe/unsubscribe reuse an existing exact
rule instead of creating a new row each time.
Reset removes the requested notification's rules in the selected scope and returns the notifiable, so calls can be chained:
use App\Notifications\InvoicePaid; $user->resetSubscriptions(InvoicePaid::class); $user->resetSubscriptions(InvoicePaid::class)->subscribe(InvoicePaid::class);
Without a model argument, reset removes only global rules; scoped rules remain. Without a matching preference, delivery is allowed unless the channel is opt-in.
Model scoping
A scope lets a recipient keep different preferences for individual organizations,
invoices or other Eloquent models. The following illustrative notification uses
your application's persisted App\Models\Organization model. Replace the message
and channel implementation with your application's requirements:
<?php namespace App\Notifications; use App\Models\Organization; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; class InvoicePaid extends Notification { public function __construct( public Organization $organization, public array $optIn = [], public bool $ignoreSubscriptions = false, ) { } public function via($notifiable): array { return ['mail']; } public function toMail($notifiable): MailMessage { return (new MailMessage()) ->subject('Invoice paid') ->line('An invoice for your organization has been paid.'); } public function getSubscriptionModel($notifiable): ?Model { return $this->organization; } public function getOptInSubscriptions(): array { return $this->optIn; } }
getSubscriptionModel($notifiable) returns the Eloquent model to use as a scope,
or null for global preferences. Rules store the concrete get_class($model)
and its id property (optional($model)->id), not a morph alias or custom primary
key returned by getKey().
use App\Models\Organization; use App\Models\User; use App\Notifications\InvoicePaid; $user = User::findOrFail(1); $organization = Organization::findOrFail(1); $user->unsubscribe(InvoicePaid::class, '*', $organization); $user->subscribe(InvoicePaid::class, 'mail', $organization); $user->notify(new InvoicePaid($organization)); // Mail is allowed by the scoped exception. $user->resetSubscriptions(InvoicePaid::class, $organization);
The scoped reset removes only that model's rules. Global rules and other scopes remain; global preferences become the fallback for this scope.
Opt-in channels
Laravel's normal notify() and Notification::send() flow reads
getOptInSubscriptions() with no arguments. It must return the channels that need
an explicit matching subscription. In the illustrative notification above, the
constructor's optIn array supplies that list:
use App\Notifications\InvoicePaid; $notification = new InvoicePaid($organization, optIn: ['mail']); $user->resetSubscriptions(InvoicePaid::class, $organization); $user->resetSubscriptions(InvoicePaid::class); $user->notify($notification); // Suppressed: no mail or wildcard subscription exists. $user->subscribe(InvoicePaid::class, 'mail', $organization); $user->notify($notification); // Allowed by the scoped mail subscription.
A matching wildcard or global subscription can also satisfy opt-in according to
the precedence below. Direct isSubscribed() checks require you to pass the opt-in
list explicitly; they do not construct a notification or call its methods.
Preference precedence
For each recipient, notification class and channel, the first matching rule wins:
- Scoped exact channel.
- Scoped wildcard (
'*'). - Global exact channel.
- Global wildcard (
'*'). - No rule: allow, unless the channel is opt-in.
Unscoped notifications start at the global rules. A global unsubscribe suppresses scoped notifications when no scoped rule exists; an explicit scoped subscription can override it. A global subscription can satisfy an opt-in channel. Rules for other recipients, notification types or model scopes are never used as fallback.
Use exact stored notification, channel and scope-class names matching the
notification class, via() channel values and get_class($model). Hand-written
values that depend on database collation (such as different case or trailing
spaces) are not guaranteed equivalent between SQL and loaded-collection matching.
Read preferences and eager-load
use App\Notifications\InvoicePaid; // Resolved decision, including wildcard/global fallback and an explicit opt-in list. $allowed = $user->isSubscribed(InvoicePaid::class, 'mail', $organization, ['mail']); // Exact tuple only: returns a NotificationSubscription or null, with no fallback. $rule = $user->findSubscription(InvoicePaid::class, 'mail', $organization); // Query all preferences, or just this scope, using the Eloquent relation. $allRules = $user->notificationSubscriptions()->get(); $scopedRules = $user->notificationSubscriptions()->model($organization)->get();
For multiple recipients, eager-load the complete relation:
use App\Models\User; use App\Notifications\InvoicePaid; $users = User::with('notificationSubscriptions')->get(); foreach ($users as $user) { $allowed = $user->isSubscribed(InvoicePaid::class, 'mail'); }
Loaded findSubscription() and isSubscribed() reads perform no additional SQL,
including misses and empty collections. Unloaded reads query the requested rules
without loading the whole relation.
- Load all rows and columns for the current notifiable. Filtered/partially selected
collections are not a complete preference set. Reload or unset copied/reattached
relations after
replicate()or mixed-model-class collection loads. subscribe(),unsubscribe(),createSubscription()andresetSubscriptions()invalidate the relation on the instance they modify. Subsequent package reads query current preferences. With strict lazy-loading prevention, call$user->load('notificationSubscriptions')before accessing the relation property.- Other instances keep their cached collections. After external writes, including
direct subscription-record changes, reload with
$user->load('notificationSubscriptions')before reading preferences.
Prefer subscribe/unsubscribe for preference changes. createSubscription() is a
direct row creation API, not an upsert.
Bypass filtering and observe suppression
The default excluded channel is database. Change
config/notification-subscriptions.php if needed:
<?php return [ 'excluded_channels' => ['database'], ];
Or bypass subscription checks for a notification using its declared public
ignoreSubscriptions property. The example class above exposes it as a constructor
argument:
use App\Notifications\InvoicePaid; $user->notify(new InvoicePaid($organization, ignoreSubscriptions: true));
Excluded channels and ignoreSubscriptions skip only this package's filtering.
Other application event listeners can still
veto delivery. isSubscribed() itself checks preferences; these bypasses belong
to the sending listener.
When this package vetoes a channel, it emits NotificationSuppressed. Register a
listener in your application's service provider boot() method, for example:
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Lacodix\LaravelNotificationSubscriptions\Events\NotificationSuppressed; Event::listen(NotificationSuppressed::class, function (NotificationSuppressed $event): void { Log::info('Notification suppressed', [ 'notification' => get_class($event->notification), 'channel' => $event->channel, ]); });
The event exposes notifiable, notification, channel and the original Laravel
NotificationSending event as event. Application-listener vetoes alone do not
emit this package event.
Upgrading
When migrating from upstream, remove liran-co/laravel-notification-subscriptions
and install lacodix/laravel-notification-subscriptions. Replace imports and manual
provider references from LiranCo\NotificationSubscriptions to
Lacodix\LaravelNotificationSubscriptions. There are no compatibility aliases or
Composer replace declaration for upstream; stored rows, schema and configuration
stay unchanged. Clear or rebuild Laravel's event cache after changing provider
references:
php artisan event:clear
# Or rebuild for your deployment:
php artisan event:cache
When updating an existing Lacodix installation, check the current PHP/Laravel requirements above. Global preferences now act as fallback for scoped notifications in both directions: existing unsubscribes may suppress delivery, and subscriptions may satisfy opt-in. Add an explicit scoped rule to keep a model-specific exception. No schema or data migration is required for this preference-resolution change.
Contributing
From a checkout with the required PHP extensions and Composer:
composer update --prefer-stable composer test composer quality composer format node --test .github/scripts/*.test.cjs
Tests use Testbench, in-memory SQLite, the package provider/migrations and Laravel's real notification sender with a local recording channel. Quality runs PHP CS Fixer and Larastan. No development lock file is committed.
The 22-lane compatibility matrix covers:
| Laravel | Testbench | PHP | Dependency selection |
|---|---|---|---|
| 11 | 9 | 8.2, 8.3, 8.4, 8.5 | prefer-stable and prefer-lowest |
| 12 | 10 | 8.2, 8.3, 8.4, 8.5 | prefer-stable and prefer-lowest |
| 13 | 11 | 8.3, 8.4, 8.5 | prefer-stable and prefer-lowest |
Lowest means the lowest resolvable stable dependencies under the development
constraints and Composer's security policy, not necessarily a framework's initial
.0 version. Laravel 11 tests have explicit advisory exceptions in the isolated
CI lane helper; they do not weaken the
package manifest or imply Laravel 11 security support.
Useful source and test entry points:
| Area | Source |
|---|---|
| Preference reads/writes and cache handling | HasNotificationSubscriptions |
| Stored rules and exact model scope | NotificationSubscription |
| Sending veto/bypass and suppression fields | Listener, event |
| Config, migration and listener registration | Provider, config, migrations |
| Precedence, isolation, mutations and real sending | Feature tests, fixtures |
Preserve exact-scope writes, recipient/type isolation, channel/scoped/global
precedence, zero-query complete loaded reads and fresh mutation lookup. Sending
suppression returns false; allowed/bypassed notifications abstain (null) so other
application listeners can run and veto. Cover behavior changes through the real
sender as well as direct preference checks.
License
MIT.