Laravel Email Preference Center
A complete, drop-in email preference management system for Laravel. Give your users full control over which emails they receive and how often โ with one-click unsubscribe, GDPR-compliant audit logs, and digest batching out of the box.
Smart Routing
Custom notification channel that automatically routes emails based on each user's preferences โ silent drops for unsubscribed users.
One-Click Unsubscribe
RFC 8058 compliant List-Unsubscribe headers for Gmail and Yahoo 2024 sender requirements.
Preference Center
Self-service UI accessible via signed URLs โ no login required. Users manage all categories in one place.
Digest Batching
Automatic daily and weekly digest batching. Queue items and let the scheduler handle delivery.
GDPR Consent Log
Immutable audit trail with IP address, user agent, and timestamp for every preference change.
Polymorphic
Works with any notifiable model, not just User. One trait to rule them all.
Live Demo
The demo application lets you interact with every feature of the package in real time โ no setup required.
All emails are captured by the log mail driver so nothing is actually sent.
For real-world code examples โ notifications, mailables, event listeners, controllers, and more โ browse the demo app's source code on GitHub: github.com/lchris44/laravel-email-preference-center-demo
Pre-seeded users
Three users are seeded with different preference configurations so you can test every scenario immediately:
| User | Marketing | Security | Digest Frequency | |
|---|---|---|---|---|
| Alice | alice@example.com |
Subscribed | Required | instant |
| Bob | bob@example.com |
Unsubscribed | Required | daily |
| Carol | carol@example.com |
Subscribed | Required | weekly |
What you can try
Fire marketing, security, digest, and billing notifications for any user. Notice how Bob's marketing notification is silently dropped, Carol's digest item is queued instead of sent, and Alice receives hers immediately.
Send a newsletter mailable and inspect the List-Unsubscribe and List-Unsubscribe-Post headers in the mail log.
Each user card on the dashboard has a signed link to their personal preference center โ manage categories and frequencies with no login.
Dispatch digest items directly with Digest notify or Dispatch digest item, then trigger Send daily digests to batch and deliver them.
Subscribe/unsubscribe users and watch the event feed update in real time. Every action is written to the immutable audit log with IP and timestamp.
The dashboard shows a live feed of all package events (PreferenceUpdated, DigestQueued, DigestSent, etc.) as they fire.
Mail log
All emails are written to storage/logs/mail.log. The dashboard shows the last 20 entries so you
can inspect headers, subjects, and body content without a real mail server.
php artisan migrate:fresh --seed in the project root.
Installation
Install the package via Composer:
composer require lchris44/laravel-email-preference-center
Publish config & run migrations
php artisan vendor:publish --tag=email-preferences-config
php artisan migrate
Add the trait to your model
Add HasEmailPreferences to any notifiable model (typically User):
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Lchris44\EmailPreferenceCenter\Traits\HasEmailPreferences;
class User extends Authenticatable
{
use HasEmailPreferences;
}
Define your categories
Open config/email-preferences.php and define the email categories your application uses:
'categories' => [
'security' => [
'label' => 'Security Alerts',
'description' => 'Password changes, login alerts, and account activity.',
'required' => true, // Users cannot unsubscribe from required categories
],
'marketing' => [
'label' => 'Marketing Emails',
'description' => 'Product updates, promotions, and newsletters.',
'required' => false,
],
'digest' => [
'label' => 'Activity Digest',
'description' => 'A summary of your recent activity.',
'required' => false,
'frequency' => ['instant', 'daily', 'weekly', 'never'],
],
],
Seed initial preferences optional
If you are installing the package on an existing app with users already in the database, run the seeder to create default preference rows for everyone upfront:
php artisan email-preferences:seed
It is also useful when adding a new category to an existing app โ re-run it and only the missing rows will be created. See Artisan Commands for the full options reference.
Quick Start
Once installed, update your notifications to use the email-preferences channel instead of mail.
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;
use Lchris44\EmailPreferenceCenter\Attributes\EmailCategory;
#[EmailCategory('marketing')]
class NewsletterNotification extends Notification
{
public function via(object $notifiable): array
{
return ['email-preferences']; // โ replaces 'mail'
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your Weekly Newsletter')
->line('Here is what\'s new this week...');
}
}
That's it. The channel will automatically:
- Check if the user is subscribed to the
marketingcategory - Respect their frequency preference (instant, daily, weekly, never)
- Queue the notification to the digest pipeline if needed
- Silently drop the notification if the user has unsubscribed
Configuration
All configuration lives in config/email-preferences.php. Here is the full reference:
return [
/*
|----------------------------------------------------------------------
| Email Categories
|----------------------------------------------------------------------
| Define your application's email categories. Each category can be
| toggled on/off and can optionally support frequency control.
*/
'categories' => [
'security' => [
'label' => 'Security Alerts',
'description' => 'Password changes, login alerts, and account activity.',
'required' => true,
],
'marketing' => [
'label' => 'Marketing Emails',
'description' => 'Product updates, promotions, and newsletters.',
'required' => false,
'frequency' => ['instant', 'daily', 'weekly', 'never'],
],
],
/*
|----------------------------------------------------------------------
| Table Names
|----------------------------------------------------------------------
*/
'table_names' => [
'preferences' => 'email_preferences',
'logs' => 'email_preference_logs',
],
/*
|----------------------------------------------------------------------
| Auto Scheduling
|----------------------------------------------------------------------
| Automatically register digest schedule commands. Set to false if
| you want to register the schedules yourself.
*/
'auto_schedule' => env('EMAIL_PREFERENCES_AUTO_SCHEDULE', true),
/*
|----------------------------------------------------------------------
| Digest Schedules (Cron expressions)
|----------------------------------------------------------------------
*/
'digest_schedules' => [
'daily' => env('EMAIL_PREFERENCES_DAILY_SCHEDULE', '0 8 * * *'), // 08:00 daily
'weekly' => env('EMAIL_PREFERENCES_WEEKLY_SCHEDULE', '0 8 * * 1'), // 08:00 Monday
],
/*
|----------------------------------------------------------------------
| Signed URL Expiry
|----------------------------------------------------------------------
*/
'signed_url_expiry_days' => env('EMAIL_PREFERENCES_URL_EXPIRY_DAYS', 30),
/*
|----------------------------------------------------------------------
| Digest Mailable
|----------------------------------------------------------------------
| The mailable used to send digest emails. Must accept:
| (mixed $notifiable, Collection $items, string $frequency)
*/
'digest_mailable' => \Lchris44\EmailPreferenceCenter\Mail\DigestMail::class,
/*
|----------------------------------------------------------------------
| Digest Queue
|----------------------------------------------------------------------
| Set a queue name to dispatch digests asynchronously, or null for sync.
*/
'digest_queue' => env('EMAIL_PREFERENCES_DIGEST_QUEUE', null),
/*
|----------------------------------------------------------------------
| Notification Category Map
|----------------------------------------------------------------------
| Map third-party notification classes to categories when you cannot
| add a PHP attribute or implement the interface yourself.
*/
'notification_categories' => [
// \Vendor\BillingNotification::class => 'billing',
],
];
Environment variables
| Variable | Default | Description |
|---|---|---|
EMAIL_PREFERENCES_AUTO_SCHEDULE | true | Auto-register digest schedules |
EMAIL_PREFERENCES_DAILY_SCHEDULE | 0 8 * * * | Daily digest cron expression |
EMAIL_PREFERENCES_WEEKLY_SCHEDULE | 0 8 * * 1 | Weekly digest cron expression |
EMAIL_PREFERENCES_URL_EXPIRY_DAYS | 30 | Signed URL expiry in days |
EMAIL_PREFERENCES_DIGEST_QUEUE | null | Queue name for async digests |
Notification Channel
The email-preferences channel is a drop-in replacement for Laravel's built-in mail channel.
It intercepts the notification, checks the recipient's preferences, and decides whether to send immediately,
queue to a digest, or silently drop the notification.
How routing works
email-preferencesor "never"
public function via(object $notifiable): array
{
// Use 'email-preferences' instead of 'mail'
return ['email-preferences', 'database'];
}
email-preferences with other channels. The preference check only applies to the email channel โ other channels like database or slack are unaffected.
Category Declaration
Every notification that uses the email-preferences channel must declare its category. There are three ways to do this:
Method 1 โ PHP Attribute Recommended
use Lchris44\EmailPreferenceCenter\Attributes\EmailCategory;
#[EmailCategory('marketing')]
class NewsletterNotification extends Notification
{
// ...
}
Method 2 โ Interface
use Lchris44\EmailPreferenceCenter\Contracts\HasEmailCategory;
class MarketingNotification extends Notification implements HasEmailCategory
{
public function emailCategory(): string
{
return 'marketing';
}
}
Method 3 โ Config Map
For third-party notifications you cannot modify, map them in the config file:
'notification_categories' => [
\Vendor\Package\BillingNotification::class => 'billing',
\Vendor\Package\ReceiptNotification::class => 'billing',
],
Preference Management
All preference methods are available on any model using the HasEmailPreferences trait.
Default behaviour โ opt-in by default
If a user has no preference row for a category, the package treats them as subscribed with instant frequency. You do not need to seed rows for new users โ they will receive emails from all categories until they explicitly change their preferences.
// User has no preference row for 'marketing'
$user->prefersEmail('marketing'); // true โ opt-in by default
$user->emailFrequency('marketing'); // 'instant' โ default frequency
wasSubscribedTo() returns false if there is no log entry for a user,
even though they are treated as subscribed at runtime. If explicit consent is required for your use case,
seed preference rows for new users on registration and call $user->subscribe() to create an
audit trail.
Read preferences
// Check if a user wants emails in a category
$user->prefersEmail('marketing'); // bool
// Get current frequency setting
$user->emailFrequency('digest'); // 'instant' | 'daily' | 'weekly' | 'never'
Update preferences
// Subscribe a user to a category
$user->subscribe('marketing', 'admin');
// Unsubscribe a user from a category
$user->unsubscribe('marketing', 'api');
// Set digest frequency
$user->setEmailFrequency('digest', 'weekly', 'preference_center');
// Frequency options: 'instant' | 'daily' | 'weekly' | 'never'
subscribe(), unsubscribe(), and setEmailFrequency() is the via source, used for the GDPR audit log. Common values: api, admin, preference_center, unsubscribe_link.
Query the Eloquent relationships
// Get all preferences for a user
$user->emailPreferences;
// Get the audit log
$user->emailPreferenceLogs;
// Query a specific preference
$user->emailPreferences()
->where('category', 'marketing')
->first();
Preference Center UI
The package ships with a self-service preference center that users can access via a signed URL โ no login required. Users can toggle subscriptions and set frequencies for all their categories in one place.
Generate a preference center URL
use Lchris44\EmailPreferenceCenter\Support\SignedUnsubscribeUrl;
$url = SignedUnsubscribeUrl::generateForCenter($user);
// Include this URL in your emails
return (new MailMessage)
->line('Manage your email preferences:')
->action('Email Preferences', $url);
Include in a Mailable
<!-- In any email template -->
<a href="{{ $preferenceCenterUrl }}">
Manage email preferences
</a>
Customize the view
Publish the package views to customize the preference center UI:
php artisan vendor:publish --tag=email-preferences-views
This publishes to resources/views/vendor/email-preferences/.
Routes
| Method | URI | Route Name | Description |
|---|---|---|---|
| GET | /email-preferences | email-preferences.center | Show the preference center form |
| POST | /email-preferences | email-preferences.center.save | Save updated preferences |
One-Click Unsubscribe
The package supports RFC 8058 one-click unsubscribe, which is required by Gmail and Yahoo for bulk senders
as of February 2024. When a user clicks "Unsubscribe" directly inside Gmail or Apple Mail, the mail client sends a
POST request to the List-Unsubscribe-Post endpoint automatically.
Add headers to a Mailable
Use the BelongsToCategory trait on any Mailable to automatically add the headers:
<?php
namespace App\Mail;
use Illuminate\Mail\Mailable;
use Lchris44\EmailPreferenceCenter\Traits\BelongsToCategory;
class NewsletterMail extends Mailable
{
use BelongsToCategory;
public function __construct(
protected $notifiable,
protected string $category = 'marketing'
) {}
public function build()
{
// BelongsToCategory automatically appends:
// List-Unsubscribe: <https://app.test/email-preferences/unsubscribe?...>
// List-Unsubscribe-Post: List-Unsubscribe=One-Click
return $this->view('emails.newsletter');
}
}
Unsubscribe routes
| Method | URI | Route Name | Description |
|---|---|---|---|
| GET | /email-preferences/unsubscribe | email-preferences.unsubscribe | Show unsubscribe confirmation page |
| POST | /email-preferences/unsubscribe | email-preferences.unsubscribe.post | RFC 8058 one-click unsubscribe endpoint |
Generate an unsubscribe URL manually
use Lchris44\EmailPreferenceCenter\Support\SignedUnsubscribeUrl;
$unsubscribeUrl = SignedUnsubscribeUrl::generate($user, 'marketing');
Digest Batching
When a user's frequency is set to daily or weekly, notifications are not sent immediately.
Instead, the channel serializes the toMail() payload from your notification and stores it as a row
in the pending_digest_items table. The scheduler then collects all pending items for each user,
passes them as a collection to DigestMail, and delivers a single batched email at the configured time.
How the pipeline works
Take this notification as an example:
#[EmailCategory('digest')]
class OrderShippedNotification extends Notification
{
public function via(object $notifiable): array
{
return ['email-preferences'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your order has shipped')
->line('Order #1234 is on its way.')
->action('Track order', url('/orders/1234'));
}
}
If the user's frequency for digest is daily, calling
$user->notify(new OrderShippedNotification()) will not send an email.
Instead, the channel stores the toMail() content โ subject, lines, action button โ as a JSON
payload in pending_digest_items. At 08:00 the next morning the scheduler picks up all pending
items for that user and delivers them as one digest email.
DigestQueue yourself when using the notification channel โ it is invoked automatically inside the channel. DigestQueue::dispatch() is only needed when you want to push items into the digest pipeline from outside a notification, for example from a queued job or an event listener (see the manual dispatch example below).
Automatic scheduling
By default (auto_schedule = true), the package registers two scheduled commands automatically:
| Frequency | Default Schedule | Cron |
|---|---|---|
| Daily | Every day at 08:00 | 0 8 * * * |
| Weekly | Monday at 08:00 | 0 8 * * 1 |
Make sure the Laravel scheduler is running:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Dispatch a digest item manually
use Lchris44\EmailPreferenceCenter\Jobs\DigestQueue;
DigestQueue::dispatch(
notifiable: $user,
category: 'digest',
type: 'order_shipped',
payload: [
'subject' => 'Your order has shipped',
'lines' => ['Order #1234 is on its way.'],
]
);
Customize the digest mailable
Publish the default digest mailable and view to customize the digest email:
php artisan vendor:publish --tag=email-preferences-mailable
Then point config to your custom class:
'digest_mailable' => \App\Mail\MyDigestMail::class,
// Constructor must accept: ($notifiable, Collection $items, string $frequency)
Async digest sending
To send digests via a queue worker instead of synchronously, set a queue name:
EMAIL_PREFERENCES_DIGEST_QUEUE=emails
GDPR Consent Log
Every preference change is recorded in an immutable audit log table (email_preference_logs).
The log captures the user's IP address, user agent, timestamp, and the action taken โ giving you a complete consent trail.
What's logged
| Action | Triggered by |
|---|---|
subscribed | $user->subscribe() |
unsubscribed | $user->unsubscribe() or one-click unsubscribe |
frequency_changed | $user->setEmailFrequency() |
Query consent history
// Was the user subscribed at a specific point in time?
$wasSubscribed = $user->wasSubscribedTo('marketing', '2026-01-01'); // bool
// Get the most recent consent record for a category
$log = $user->lastConsentFor('marketing');
// $log->action => 'subscribed'
// $log->via => 'preference_center'
// $log->ip_address => '192.168.1.1'
// $log->user_agent => 'Mozilla/5.0...'
// $log->created_at => Carbon instance
// Query all logs directly
$user->emailPreferenceLogs()
->where('category', 'marketing')
->orderBy('created_at', 'desc')
->get();
Events
The package fires events at each key lifecycle moment. Listen to them in your EventServiceProvider or using Laravel's #[Listen] attribute.
| Event | Fired when | Payload |
|---|---|---|
PreferenceUpdated |
Any preference is changed | $notifiable, $category, $action, $via |
UserUnsubscribed |
User unsubscribes from a category | $notifiable, $category, $via |
DigestQueued |
An item is added to the digest pipeline | $notifiable, $category, $frequency |
DigestReadyToSend |
A digest batch is ready to be dispatched | $frequency |
DigestSent |
A digest email was sent to a user | $notifiable, $frequency, $itemCount |
Listening to events
use Lchris44\EmailPreferenceCenter\Events\PreferenceUpdated;
use Lchris44\EmailPreferenceCenter\Events\UserUnsubscribed;
protected $listen = [
PreferenceUpdated::class => [
\App\Listeners\SyncPreferencesToCrm::class,
],
UserUnsubscribed::class => [
\App\Listeners\NotifyMarketingTeam::class,
],
];
Artisan Commands
email-preferences:send-digests
Manually trigger a digest send cycle. Useful for testing or when the scheduler is disabled.
# Send all queued daily digest items
php artisan email-preferences:send-digests daily
# Send all queued weekly digest items
php artisan email-preferences:send-digests weekly
email-preferences:seed
Creates default preference rows for every notifiable model that doesn't have them yet. Safe to run multiple times โ existing rows are skipped unless --force is passed.
When to use it
You already have thousands of users. Without seeding, preference rows are created on the fly the first time each user is encountered. Running the seeder upfront gives every user explicit rows from day one, making queries and reporting predictable.
You add an announcements category six months in. Existing users have rows for the old categories but nothing for the new one. The seeder fills in only the missing rows, so no user is left out of the new category.
With --force, it overwrites existing rows and resets everyone to the default frequency. Useful during development, after a botched migration, or when changing your default frequency strategy.
# Normal use โ skips rows that already exist
php artisan email-preferences:seed
# Target a specific model (defaults to App\Models\User)
php artisan email-preferences:seed --model=App\\Models\\Organization
# Reset everyone to weekly digest
php artisan email-preferences:seed --frequency=weekly --force
Options
| Option | Default | Description |
|---|---|---|
--model | App\Models\User | Fully-qualified notifiable model class to seed |
--frequency | instant | Default frequency for frequency-controlled categories |
--force | โ | Overwrite preference rows that already exist |
API Reference
Complete method reference for the HasEmailPreferences trait.
prefersEmail(string $category): boolReturns true if the user is subscribed and their frequency is not never.
emailFrequency(string $category): stringReturns the user's current frequency for the given category: 'instant', 'daily', 'weekly', or 'never'.
subscribe(string $category, string $via = 'api'): voidSubscribe the user to a category and log the consent action.
unsubscribe(string $category, string $via = 'api'): voidUnsubscribe the user from a category and log the action. Has no effect on required categories.
setEmailFrequency(string $category, string $frequency, string $via = 'api'): voidSet the delivery frequency for a category. Must be one of the values configured for that category's frequency array.
wasSubscribedTo(string $category, string|Carbon $date): boolCheck whether the user was subscribed to a category at a specific point in time. Queries the immutable audit log.
lastConsentFor(string $category): ?EmailPreferenceLogGet the most recent consent log entry for a category. Returns null if no record exists.
emailPreferences(): MorphManyEloquent relationship to all EmailPreference records for this model.
emailPreferenceLogs(): MorphManyEloquent relationship to all EmailPreferenceLog audit records for this model.
Database Schema
email_preferences
| Column | Type | Description |
|---|---|---|
id | bigint PK | Auto-increment primary key |
notifiable_type | string | Polymorphic model class name |
notifiable_id | bigint | Polymorphic model ID |
category | string | Category key (e.g. marketing) |
frequency | string | instant | daily | weekly | never |
unsubscribed_at | timestamp? | Set when unsubscribed, null when subscribed |
created_at | timestamp | |
updated_at | timestamp |
Unique index on (notifiable_type, notifiable_id, category).
email_preference_logs Immutable
| Column | Type | Description |
|---|---|---|
id | bigint PK | |
notifiable_type | string | Polymorphic type |
notifiable_id | bigint | Polymorphic ID |
category | string | Category key |
action | string | subscribed | unsubscribed | frequency_changed |
via | string | Source of the change (e.g. api, preference_center) |
ip_address | string? | Up to 45 chars (supports IPv6) |
user_agent | text? | Browser/client user agent string |
created_at | timestamp | Indexed. No updated_at โ rows are never modified. |
pending_digest_items
| Column | Type | Description |
|---|---|---|
id | bigint PK | |
notifiable_type | string | Polymorphic type |
notifiable_id | bigint | Polymorphic ID |
category | string | Category key |
frequency | string | daily | weekly |
type | string | Item type identifier (e.g. order_shipped) |
payload | json | Serialized MailMessage data |
created_at | timestamp | |
updated_at | timestamp |
Routes Reference
All package routes are registered under the web middleware group and protected by Laravel's signed URL verification.
| Method | URI | Route Name | Description |
|---|---|---|---|
| GET | /email-preferences |
email-preferences.center |
Preference center โ show all categories |
| POST | /email-preferences |
email-preferences.center.save |
Save updated preferences |
| GET | /email-preferences/unsubscribe |
email-preferences.unsubscribe |
Unsubscribe confirmation page |
| POST | /email-preferences/unsubscribe |
email-preferences.unsubscribe.post |
RFC 8058 one-click unsubscribe endpoint |
403 response.