liran-co/laravel-notification-subscriptions
Laravel Notification Subscriptions adds flexible, database-backed notification preferences to your app. Let users subscribe or unsubscribe from specific notification types and channels, with polymorphic subscribers, simple traits, and clean APIs for checking and managing subscriptions.
The package answers a narrow question: given a user and a notification class, on which channels should we actually deliver it? It ships a polymorphic notification_subscriptions table, a NotificationSubscription model, and a HasNotificationSubscriptions trait you put on the user (or any subscriber) model.
Minimal setup:
composer require liran-co/laravel-notification-subscriptions
php artisan vendor:publish --provider="LiranCo\NotificationSubscriptions\NotificationSubscriptionsServiceProvider"
php artisan migrate
Then wire the trait:
use LiranCo\NotificationSubscriptions\Traits\HasNotificationSubscriptions;
class User extends Authenticatable
{
use HasNotificationSubscriptions;
}
Where to look first. Read the published config (config/notification-subscriptions.php) and the src/ tree in this order: the trait (HasNotificationSubscriptions), the NotificationSubscription model, then any middleware/service the package registers. The public surface is small — the trait methods are the whole API, and the model is a thin polymorphic pivot. Skim the migration to see the exact columns (subscribable, notification, channel, and the unique constraint) because you will be querying it directly when you need to bypass the trait.
First use case. The canonical one is a "notification preferences" settings page. A user toggles off email notifications for OrderShipped but keeps database/in-app. You render a form listing notification classes × channels, and persist the toggles via the trait. Then, at send time, you consult isSubscribedTo() inside the notification's via() method (or a shouldSend() guard) so the user's choice is honored without touching every call site.
If you only have one or two notifications, do not install this — a boolean column on users is cheaper and clearer. Install it when you have a matrix of (notification class × channel × user) preferences that is expected to grow.
Define the catalogue, not the subscriptions. The single most important integration decision is where the list of "subscribable" notification types lives. Keep it in a config array or a dedicated registry class, not on the subscription rows themselves. That way you can render the settings matrix (all types, subscribed-or-not) without a union query that misses types the user has never toggled. The subscription table stores opted-in/opted-out state; the catalogue is static code.
Check at delivery time, in via(). The idiomatic guard is inside the notification:
public function via($notifiable): array
{
return $notifiable
->notificationChannelsFor(self::class, ['mail', 'database'])
->all();
}
This keeps the check close to the channels it governs and works for both notify() and Notification::send(). Do not put subscription logic in shouldSend() unless you want the whole notification suppressed across all channels uniformly — shouldSend() cannot distinguish mail from database.
Model defaults explicitly. A new user has no rows, so "subscribed or not?" is ambiguous. Decide early: treat absence as opt-in (default subscribed) or opt-out. The package exposes defaults via config/registry, but the practical pattern is to seed subscription rows on registration from the catalogue, so the settings page and the runtime check read the same source of truth and you never rely on a fallback branch. This also makes bulk changes and analytics trivial.
Bulk update from a settings form. The clean pattern is full-replacement per submission: within a transaction, $user->notificationSubscriptions()->delete() then insert the submitted (notification, channel) pairs. Partial diffing buys you little here and introduces race conditions with concurrent tabs. Wrap it in a form request that validates every submitted notification class against your registry — never trust a notification string from the client, since it ends up being compared against class names.
Channel-level granularity or class-level, pick one. The schema supports a channel dimension, so OrderShipped can be mail-off / database-on. If your product only needs per-notification on/off, ignore the channel dimension entirely (always write a sentinel channel like '*') rather than pretending you support channels you never expose. Mixed semantics are the most common source of "why is it still sending?" bugs.
Integrate with queued notifications. Because via() is evaluated when the notification is dispatched, not when the queued job runs, a user who unsubscribes between dispatch and delivery still gets the message. If that matters, add a shouldSend() check that re-reads the subscription, or dispatch a fresh notification after the preference change. Document the behavior either way — this is a real product decision disguised as a technical one.
Compose with Laravel's own notification gating. Laravel 9+ gives you Notification::route() and notifiable resolution; use that for routing, and use this package only for the preference decision. Do not try to make the package solve channel routing (SMS numbers, Slack webhooks) — that belongs in routeNotificationFor*.
Low maintenance signal, so pin and audit. Last release is April 2024, ~127 stars, zero dependents. That is not disqualifying, but treat it as vendored code you happen to get via Composer. Pin an exact version, and be prepared to fork. Read src/ fully — it is small enough to do in 15 minutes — before shipping. Check the migration for the unique index on (subscribable_type, subscribable_id, notification, channel); without it, duplicate rows will make isSubscribedTo() lie in whichever direction your query happens to short-circuit.
Class-string comparison is the whole contract. Subscriptions are stored as the fully-qualified notification class name. Rename or move a notification class and every existing subscription silently disappears (no rows match). If you anticipate refactors, store a stable key via a registry and map keys → classes in code, rather than storing ::class directly. At minimum, add a test that asserts every class in your registry is a real, existing notification.
Polymorphic subscribers multiply the test matrix. Because the pivot is polymorphic, User and Team (or whatever else) can both subscribe, but they will not do so automatically — the trait does nothing on its own. If you have multiple notifiable models, you must either duplicate the catalogue logic per model or abstract it; otherwise the settings page works for users and silently 404s/empty-lists for teams.
Cache invalidation is your problem. The trait queries the table on every notification dispatch. For high-volume apps, wrap the lookup in Cache::remember("notif-subs:{$type}:{$id}", ...) and — critically — forget that key in every write path and in any admin/queue path that mutates subscriptions. The package does not hook model events for you. If you skip caching, at least eager-load subscriptions when dispatching to a collection of users (Notification::send($users, ...)) to avoid N+1.
Middleware registered by the provider may surprise you. If the package registers a global or group middleware to enforce subscriptions, it will run on routes you did not expect and can return 403s during testing or for API clients. Inspect the service provider's boot() and remove/scope the middleware if your app handles the check in via() instead. Duplicated enforcement is harmless in behavior but confusing in debugging.
Debugging: query the pivot directly. When "I unsubscribed but still got the email," the fastest path is NotificationSubscription::where('subscribable_id', $user->id)->where('notification', OrderShipped::class)->get() — not stepping through via(). Nine times out of ten it is a stale cache, a duplicate row, or the class name was stored from a renamed/aliased class.
Extension points are thin. Expect to extend with: a custom registry class for the catalogue, a custom settings controller/form request, and possibly a forked trait if you need cache-aware isSubscribedTo(). Do not fight the package into becoming a full preference engine — treat it as the schema + the trait, and own the UX, validation, and caching yourself. That split is where the package actually earns its keep.
Test the negative case first. Write a feature test that unsubscribes and asserts Notification::fake() records nothing on that channel, and a second that asserts the other channels still fire. The channel-granularity behavior is the part most likely to differ from your assumptions, and it is cheaper to learn from a red test than from a production incident.
How can I help you explore Laravel packages today?