humweb / notification-subscriptions
This is my package notification-subscriptions
Requires
- php: ^8.2|^8.3
- illuminate/contracts: ^10.0
- illuminate/database: ^10.0
- illuminate/notifications: ^10.0
- illuminate/support: ^10.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.0
- nunomaduro/collision: ^7.9
- nunomaduro/larastan: ^2.0.1
- orchestra/testbench: ^8.0
- pestphp/pest: ^2.0
- pestphp/pest-plugin-arch: ^2.0
- pestphp/pest-plugin-laravel: ^2.0
- phpstan/extension-installer: ^1.1
- phpstan/phpstan-deprecation-rules: ^1.0
- phpstan/phpstan-phpunit: ^1.0
This package is auto-updated.
Last update: 2025-05-26 00:15:36 UTC
README
Notification Subscriptions allows your users to subscribe to certain notifications in your application, with support for per-channel preferences and notification digests (daily/weekly summaries).
For full documentation, please see the docs/index.md page.
Quick Links
Installation
You can install the package via composer:
composer require humweb/notification-subscriptions
You can publish and run the migrations with:
php artisan vendor:publish --tag="notification-subscriptions-migrations"
php artisan migrate
This will create two tables:
notification_subscriptions
: Stores user subscriptions, including channel and digest preferences.pending_notifications
: Temporarily stores notifications that are scheduled for digest delivery.
You can publish the config file with:
php artisan vendor:publish --tag="notification-subscriptions-config"
This will create a config/notification-subscriptions.php
file.
Setup
1. Prepare Your User Model
Add the Humweb\\Notifications\\Traits\\Subscribable
trait to your User
model (or any model you want to make subscribable).
namespace App\\Models; use Humweb\\Notifications\\Traits\\Subscribable; use Illuminate\\Foundation\\Auth\\User as Authenticatable; use Illuminate\\Notifications\\Notifiable; // Usually already present class User extends Authenticatable { use Notifiable, Subscribable; // Add Subscribable here // ... rest of your User model }
2. Configure Notification Types
Open the config/notification-subscriptions.php
file. This is where you define all the notification types users can subscribe to, their available channels, and digest settings.
<?php use Humweb\\Notifications\\Models\\NotificationSubscription; // Define your User model path // use App\\Models\\User; return [ // Typically App\\Models\\User::class 'user_model' => \\Humweb\\Notifications\\Database\\Stubs\\User::class, // Example, change to your User model 'subscription_model' => NotificationSubscription::class, 'table_name' => 'notification_subscriptions', 'pending_notifications_table_name' => 'pending_notifications', // Table for digest items // Default notification class for digests 'digest_notification_class' => \\Humweb\\Notifications\\Notifications\\UserNotificationDigest::class, // Available digest intervals for users to choose from 'digest_intervals' => [ 'immediate' => 'Immediate', 'daily' => 'Daily Digest', 'weekly' => 'Weekly Digest', ], 'notifications' => [ 'app:updates' => [ 'label' => 'Application Updates', 'description' => 'Receive notifications about new features and important updates.', 'class' => App\\Notifications\\AppUpdatesNotification::class, // Optional: FQCN of your Laravel Notification 'channels' => [ ['name' => 'mail', 'label' => 'Email'], ['name' => 'database', 'label' => 'Site Notification'], ] ], 'comment:created' => [ 'label' => 'New Comments', 'description' => 'Get notified about new comments on your content.', 'class' => App\\Notifications\\NewComment::class, // Example 'channels' => [ ['name' => 'mail', 'label' => 'Email'], ['name' => 'database', 'label' => 'Site Notification'], ] ], // Add more notification types... ], ];
Key Configuration Options:
user_model
: The class name of your User model.subscription_model
: The Eloquent model for storing subscriptions (defaults toHumweb\Notifications\Models\NotificationSubscription
).table_name
: The database table for subscriptions (defaults tonotification_subscriptions
).pending_notifications_table_name
: Database table for storing notifications pending digest (defaults topending_notifications
).digest_notification_class
: The default Laravel Notification class to use for sending digests. You should create this class. It will receive the channel and a collection of pending notification data.digest_intervals
: An associative array defining the available digest periods users can select (e.g.,['immediate' => 'Immediate', 'daily' => 'Daily Digest']
). Keys are used internally, values are for display.notifications
: An array where each key is a unique string identifying a notification type (e.g.,app:updates
,comment:created
).label
: A human-readable name for the notification.description
: A more detailed explanation.class
: (Optional) The FQCN of the corresponding Laravel Notification class. Useful for reference or dynamic dispatch.channels
: An array of available delivery channels. Each channel item should be an array with:name
: The technical identifier (e.g., 'mail', 'database').label
: A human-readable name for the UI.
3. Prepare Your Notification Classes (Optional but Recommended)
For seamless integration, especially with digest preferences, your Laravel Notification classes can use two traits provided by this package:
Humweb\\Notifications\\Traits\\DispatchesNotifications
: Adds a staticdispatch()
method to your notification. This method automatically handles checking user subscriptions and either sends the notification immediately or queues it for a digest.Humweb\\Notifications\\Traits\\ChecksSubscription
: Provides thevia()
method. When a notification is sent (either directly or via thedispatch()
method fromDispatchesNotifications
), thisvia()
method ensures it only goes out through channels the user is immediately subscribed to. Non-immediate preferences are handled by the digest system.
namespace App\\Notifications; use Humweb\\Notifications\\Contracts\\SubscribableNotification; use Humweb\\Notifications\\Traits\\ChecksSubscription; use Humweb\\Notifications\\Traits\\DispatchesNotifications; use Illuminate\\Bus\\Queueable; use Illuminate\\Notifications\\Notification; // use Illuminate\\Contracts\\Queue\\ShouldQueue; // If you want to queue it class NewComment extends Notification implements SubscribableNotification //, ShouldQueue { use Queueable, DispatchesNotifications, ChecksSubscription; public $comment; // Your notification constructor public function __construct($comment) { $this->comment = $comment; } // Required by SubscribableNotification // Must match a key in your config/notification-subscriptions.php 'notifications' array public static function subscriptionType(): string { return 'comment:created'; } // Standard Laravel Notification methods // The via() method is supplied by ChecksSubscription trait. // It will automatically filter channels based on immediate user subscriptions. public function toMail($notifiable) { return (new \\Illuminate\\Notifications\\Messages\\MailMessage) ->line('A new comment was added on your post: ' . $this->comment->post_title) ->action('View Comment', url('/posts/' . $this->comment->post_id . '#comment-' . $this->comment->id)) ->line('Thank you for using our application!'); } public function toArray($notifiable) { return [ 'comment_id' => $this->comment->id, 'comment_body' => $this->comment->body, // ... other data ]; } }
Implement the Humweb\\Notifications\\Contracts\\SubscribableNotification
interface, which requires a static subscriptionType()
method. This method should return the string key that identifies this notification in your notification-subscriptions.php
config file.
4. Create a Digest Notification Class
You need to create a notification class that will be responsible for sending the digest. The class name is specified in config/notification-subscriptions.php
under digest_notification_class
.
This class will receive two arguments in its constructor: the channel it's being sent for, and a collection of pending notification data.
// app/Notifications/UserNotificationDigest.php namespace App\\Notifications; use Illuminate\\Bus\\Queueable; use Illuminate\\Notifications\\Notification; use Illuminate\\Contracts\\Queue\\ShouldQueue; use Illuminate\\Notifications\\Messages\\MailMessage; use Illuminate\\Support\\Collection; class UserNotificationDigest extends Notification implements ShouldQueue { use Queueable; public string $channel; public Collection $pendingNotificationsData; public function __construct(string $channel, Collection $pendingNotificationsData) { $this->channel = $channel; $this->pendingNotificationsData = $pendingNotificationsData; } public function via($notifiable): array { // Send the digest via the channel it was originally intended for return [$this->channel]; } public function toMail($notifiable): MailMessage { $mailMessage = (new MailMessage) ->subject('Your Notification Digest'); if ($this->pendingNotificationsData->isEmpty()) { $mailMessage->line('You have no new notifications in this digest period.'); return $mailMessage; } $mailMessage->line('Here is a summary of your notifications:'); foreach ($this->pendingNotificationsData as $item) { // Customize how each item in the digest is displayed // $item['class'] is the original notification class // $item['data'] contains the original constructor arguments for that notification // $item['created_at'] is when the original notification was triggered $mailMessage->line(\"--- ({$item['created_at']->format('M d, H:i')}) ---\"); $mailMessage->line(\"Type: {$item['class']}\"); // Example // You might want to load $item['data'] into the original notification class // and call a ->toDigestMail() method on it, or format data directly. $dataString = implode(', ', array_map(fn($k, $v) => \"$k: \" . (is_object($v) || is_array($v) ? json_encode($v) : $v), array_keys($item['data']), $item['data'])); $mailMessage->line(\"Details: {$dataString}\"); } return $mailMessage; } public function toArray($notifiable): array { return [ 'message' => 'You have new notifications in your digest.', 'count' => $this->pendingNotificationsData->count(), 'items' => $this->pendingNotificationsData->map(function ($item) { return [ 'original_class' => $item['class'], 'data' => $item['data'], 'triggered_at' => $item['created_at']->toIso8601String(), ]; })->all(), ]; } }
5. Schedule the Digest Command
The package includes an Artisan command notifications:send-digests
to process and send due digests. You should schedule this command to run periodically (e.g., every 5 or 15 minutes) in your app/Console/Kernel.php
file:
// app/Console/Kernel.php protected function schedule(Schedule $schedule) { // ... $schedule->command('notifications:send-digests')->everyFifteenMinutes(); // ... }
Usage
Managing Subscriptions
The Subscribable
trait adds several methods to your User model:
Subscribing (with Digest Options)
To subscribe a user to a specific notification type, channel, and optionally specify digest preferences:
$user = Auth::user(); // Subscribe to 'app:updates' via 'mail', receive immediately (default) $user->subscribe('app:updates', 'mail'); // Subscribe to 'comment:created' via 'database', receive daily at 9:00 AM $user->subscribe('comment:created', 'database', 'daily', '09:00:00'); // Subscribe to 'newsletter:marketing' via 'mail', receive weekly on Mondays at 8:30 AM $user->subscribe('newsletter:marketing', 'mail', 'weekly', '08:30:00', 'monday');
Parameters for subscribe()
:
string $type
: The notification type key (e.g.,comment:created
).string $channel
: The channel name (e.g.,mail
,database
).string $digestInterval = 'immediate'
: Optional. The digest preference.'immediate'
: Send as soon as it occurs.'daily'
: Include in a daily digest.'weekly'
: Include in a weekly digest. (These keys should match those defined inconfig('notification-subscriptions.digest_intervals')
).
?string $digestAtTime = null
: Optional. For 'daily' or 'weekly' digests, the time of day (HH:MM:SS or HH:MM) to send the digest.?string $digestAtDay = null
: Optional. For 'weekly' digests, the day of the week (e.g., 'monday', 'tuesday') to send the digest.
If the user is already subscribed to that specific type and channel, their digest preferences will be updated.
Unsubscribing from a Type and Channel
$user->unsubscribe('app:updates', 'mail');
Checking Subscription Status
if ($user->isSubscribedTo('app:updates', 'mail')) { // User is subscribed (could be immediate or digest) }
Getting Full Subscription Details
To get the full details of a subscription, including digest preferences:
$details = $user->getSubscriptionDetails('comment:created', 'database'); if ($details) { echo "Interval: " . $details->digest_interval; // 'immediate', 'daily', 'weekly' echo "Time: " . $details->digest_at_time; // e.g., '09:00:00' or null echo "Day: " . $details->digest_at_day; // e.g., 'monday' or null echo "Last Digest Sent: " . $details->last_digest_sent_at; // Carbon instance or null }
This returns a NotificationSubscription
model instance or null
.
Other Subscription Management Methods
$user->getSubscribedChannels(string $type)
: Get channel names for a type (any digest preference).$user->unsubscribeFromType(string $type)
: Unsubscribe from all channels/digest settings for a type.$user->unsubscribeFromAll()
: Unsubscribe from everything.$user->subscriptions
: Eloquent relation to get allNotificationSubscription
models.
Dispatching Notifications
If you've set up your Notification classes with the DispatchesNotifications
and ChecksSubscription
traits:
use App\\Notifications\\NewComment; $comment = // ... your comment model ... $userToNotify = // ... the user who should receive this (if subscribed) ... // This static dispatch method handles everything: // - Checks if users are subscribed to 'comment:created' // - If 'immediate' for a channel, sends via that channel (respecting via() from ChecksSubscription) // - If 'daily' or 'weekly', stores it in 'pending_notifications' table for the digest command NewComment::dispatch($comment);
The DispatchesNotifications::dispatch()
method will find all users subscribed to the notification's subscriptionType()
. For each user:
- If they have an "immediate" subscription on any channel for this type, the notification will be sent immediately (the
ChecksSubscription::via()
method on your notification will ensure it only uses the specific immediate channels). - If they have "daily" or "weekly" subscriptions, the notification details are stored in the
pending_notifications
table. Thenotifications:send-digests
command will later process these.
If you are not using the DispatchesNotifications
trait, you'll need to implement this logic yourself:
- Identify users to notify.
- For each user, check their subscription for the notification type and channel.
- If "immediate", send it.
- If "digest", store it in
pending_notifications
(seeHumweb\Notifications\Models\PendingNotification
model).
Listing Available Notification Types & Channels
Retrieve configured types and channels (e.g., for a settings UI):
use Humweb\\Notifications\\Facades\\NotificationSubscriptions; $types = NotificationSubscriptions::getSubscribableNotificationTypes(); $availableDigestIntervals = NotificationSubscriptions::getDigestIntervals(); // Get configured digest intervals // $types will be an array like in your config // $availableDigestIntervals will be like ['immediate' => 'Immediate', ...]
Frontend Example (Vue/Inertia)
Here's an example of how you might build a notification settings page using Vue and Inertia.
Controller (NotificationSubscriptionController.php
- example):
You would typically create a controller to handle fetching settings and updating them.
<?php namespace App\\Http\\Controllers; use Illuminate\\Http\\Request; use Illuminate\\Support\\Facades\\Auth; use Humweb\\Notifications\\Facades\\NotificationSubscriptions as NotificationSettingsFacade; use Inertia\\Inertia; class NotificationSubscriptionController extends Controller { public function index() { $user = Auth::user(); $notificationTypes = NotificationSettingsFacade::getSubscribableNotificationTypes(); $availableDigestIntervals = NotificationSettingsFacade::getDigestIntervals(); $settings = []; foreach ($notificationTypes as $typeKey => $typeDetails) { $channels = []; foreach ($typeDetails['channels'] as $channelConfig) { $subscription = $user->getSubscriptionDetails($typeKey, $channelConfig['name']); $channels[] = [ 'name' => $channelConfig['name'], 'label' => $channelConfig['label'], 'subscribed' => (bool) $subscription, 'digest_interval' => $subscription->digest_interval ?? 'immediate', 'digest_at_time' => $subscription->digest_at_time ? substr($subscription->digest_at_time, 0, 5) : '09:00', // HH:MM 'digest_at_day' => $subscription->digest_at_day ?? 'monday', ]; } $settings[$typeKey] = [ 'label' => $typeDetails['label'], 'description' => $typeDetails['description'], 'channels' => $channels, ]; } return Inertia::render('Profile/NotificationSettings', [ 'notificationSettings' => $settings, 'availableDigestIntervals' => $availableDigestIntervals, // Example days of the week 'availableDaysOfWeek' => [ 'monday' => 'Monday', 'tuesday' => 'Tuesday', 'wednesday' => 'Wednesday', 'thursday' => 'Thursday', 'friday' => 'Friday', 'saturday' => 'Saturday', 'sunday' => 'Sunday' ], ]); } public function store(Request $request) { $request->validate([ 'type' => 'required|string', 'channel' => 'required|string', 'subscribed' => 'required|boolean', 'digest_interval' => 'required|string|in:' . implode(',', array_keys(NotificationSettingsFacade::getDigestIntervals())), 'digest_at_time' => 'nullable|required_if:digest_interval,daily|required_if:digest_interval,weekly|date_format:H:i', 'digest_at_day' => 'nullable|required_if:digest_interval,weekly|string|in:monday,tuesday,wednesday,thursday,friday,saturday,sunday', ]); $user = Auth::user(); if ($request->subscribed) { $user->subscribe( $request->type, $request->channel, $request->digest_interval, $request->digest_at_time ? $request->digest_at_time . ':00' : null, // Append seconds $request->digest_at_day ); } else { $user->unsubscribe($request->type, $request->channel); } return back()->with('success', 'Notification settings updated.'); } }
Vue Component (resources/js/Pages/Profile/NotificationSettings.vue
- example):
<template> <div> <h1>Notification Settings</h1> <div v-for="(type, typeKey) in notificationSettings" :key="typeKey" class="mb-8 p-4 border rounded" > <h2 class="text-xl font-semibold">{{ type.label }}</h2> <p class="text-sm text-gray-600 mb-3">{{ type.description }}</p> <div v-for="channel in type.channels" :key="channel.name" class="mb-6 p-3 border-l-4 rounded" > <h3 class="text-lg"> {{ channel.label }} ({{ channel.name }}) </h3> <div class="mt-2"> <label class="flex items-center"> <input type="checkbox" :checked="channel.subscribed" @change="toggleSubscription(typeKey, channel)" class="form-checkbox h-5 w-5 text-blue-600" /> <span class="ml-2 text-gray-700">Subscribed</span> </label> </div> <div v-if="channel.subscribed" class="mt-3 space-y-2 pl-4"> <div> <label :for="`interval-${typeKey}-${channel.name}`" class="block text-sm font-medium text-gray-700" >Delivery Preference:</label > <select :id="`interval-${typeKey}-${channel.name}`" v.model="channel.digest_interval" @change="updateSubscription(typeKey, channel)" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" > <option v-for="(label, key) in availableDigestIntervals" :key="key" :value="key" > {{ label }} </option> </select> </div> <div v-if="channel.digest_interval === 'daily' || channel.digest_interval === 'weekly'" > <label :for="`time-${typeKey}-${channel.name}`" class="block text-sm font-medium text-gray-700" >Time:</label > <input type="time" :id="`time-${typeKey}-${channel.name}`" v.model="channel.digest_at_time" @change="updateSubscription(typeKey, channel)" class="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md" /> </div> <div v-if="channel.digest_interval === 'weekly'"> <label :for="`day-${typeKey}-${channel.name}`" class="block text-sm font-medium text-gray-700" >Day of the Week:</label > <select :id="`day-${typeKey}-${channel.name}`" v.model="channel.digest_at_day" @change="updateSubscription(typeKey, channel)" class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" > <option v-for="(label, key) in availableDaysOfWeek" :key="key" :value="key" > {{ label }} </option> </select> </div> </div> </div> </div> </div> </template> <script setup> import { useForm } from "@inertiajs/vue3"; import { watch } from "vue"; const props = defineProps({ notificationSettings: Object, availableDigestIntervals: Object, availableDaysOfWeek: Object, }); // Use a reactive form for settings to easily watch for changes. // This is a simplified approach; for complex forms, consider structuring 'form.data' more directly // or using multiple forms. const form = useForm({ type: "", channel: "", subscribed: false, digest_interval: "immediate", digest_at_time: "09:00", digest_at_day: "monday", }); function updateSubscription(typeKey, channel) { form.type = typeKey; form.channel = channel.name; form.subscribed = channel.subscribed; // Assumed to be true if we are updating digest prefs form.digest_interval = channel.digest_interval; form.digest_at_time = channel.digest_at_time; form.digest_at_day = channel.digest_at_day; // Normalize time for backend if it's just HH:MM let timeToSend = channel.digest_at_time; if (timeToSend && timeToSend.length === 5) { // HH:MM // The backend validation expects H:i, so this should be fine. // The controller appends ':00' if needed for subscribe method. } form.post(route("notifications.subscriptions.store"), { // Assuming you have this route preserveScroll: true, onSuccess: () => { // Maybe show a toast }, onError: (errors) => { console.error("Error updating subscription:", errors); // Revert optimistic updates if necessary or show error messages }, }); } function toggleSubscription(typeKey, channel) { channel.subscribed = !channel.subscribed; // Optimistic update if (!channel.subscribed) { // If unsubscribing, also set digest to immediate as a default channel.digest_interval = "immediate"; } updateSubscription(typeKey, channel); } </script>
Make sure to define the route notifications.subscriptions.store
in your routes/web.php
(or api.php
) pointing to your controller's store
method.
Testing
composer test
or with coverage:
XDEBUG_MODE=coverage ./vendor/bin/pest --coverage
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
License
The MIT License (MIT). Please see License File for more information.