Laravel Package ยท v1.2.1

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

Live
Demo
Interactive dashboard ยท Real package ยท Log mail driver
Open โ†’

Pre-seeded users

Three users are seeded with different preference configurations so you can test every scenario immediately:

UserEmailMarketingSecurityDigest 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

๐Ÿ“ฌ
Notification Channel

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.

๐Ÿ”—
Newsletter Mailable + RFC 8058

Send a newsletter mailable and inspect the List-Unsubscribe and List-Unsubscribe-Post headers in the mail log.

๐ŸŽ›๏ธ
Preference Center

Each user card on the dashboard has a signed link to their personal preference center โ€” manage categories and frequencies with no login.

๐Ÿ“‹
Digest Pipeline

Dispatch digest items directly with Digest notify or Dispatch digest item, then trigger Send daily digests to batch and deliver them.

๐Ÿ”’
GDPR Consent Log

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.

๐Ÿ“ก
Event Feed

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.

To reset the demo to a clean state, run php artisan migrate:fresh --seed in the project root.

Installation

Install the package via Composer:

Terminal
composer require lchris44/laravel-email-preference-center

Publish config & run migrations

Terminal
php artisan vendor:publish --tag=email-preferences-config
php artisan migrate
Auto-Discovery โ€” No manual service provider registration is needed. Laravel's package auto-discovery registers everything automatically.

Add the trait to your model

Add HasEmailPreferences to any notifiable model (typically User):

app/Models/User.php
<?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:

config/email-preferences.php
'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:

Terminal
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.

app/Notifications/NewsletterNotification.php
<?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 marketing category
  • 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:

config/email-preferences.php
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

VariableDefaultDescription
EMAIL_PREFERENCES_AUTO_SCHEDULEtrueAuto-register digest schedules
EMAIL_PREFERENCES_DAILY_SCHEDULE0 8 * * *Daily digest cron expression
EMAIL_PREFERENCES_WEEKLY_SCHEDULE0 8 * * 1Weekly digest cron expression
EMAIL_PREFERENCES_URL_EXPIRY_DAYS30Signed URL expiry in days
EMAIL_PREFERENCES_DIGEST_QUEUEnullQueue 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

Notification dispatched via email-preferences
โ†“
Look up user's preference for the notification's category
โ†“
unsubscribed
or "never"
Silent drop
instant
Send immediately
daily
Queue to daily digest
weekly
Queue to weekly digest
app/Notifications/OrderShippedNotification.php
public function via(object $notifiable): array
{
    // Use 'email-preferences' instead of 'mail'
    return ['email-preferences', 'database'];
}
Tip: You can combine 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

Notification class
use Lchris44\EmailPreferenceCenter\Attributes\EmailCategory;

#[EmailCategory('marketing')]
class NewsletterNotification extends Notification
{
    // ...
}

Method 2 โ€” Interface

Notification class
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:

config/email-preferences.php
'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.

PHP
// User has no preference row for 'marketing'
$user->prefersEmail('marketing');   // true  โ€” opt-in by default
$user->emailFrequency('marketing'); // 'instant' โ€” default frequency
GDPR note: The implicit default is not recorded in the consent log. 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

PHP
// 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

PHP
// 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'
The second argument to 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

PHP
// 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

PHP
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

Blade template
<!-- In any email template -->
<a href="{{ $preferenceCenterUrl }}">
    Manage email preferences
</a>

Customize the view

Publish the package views to customize the preference center UI:

Terminal
php artisan vendor:publish --tag=email-preferences-views

This publishes to resources/views/vendor/email-preferences/.

Routes

MethodURIRoute NameDescription
GET/email-preferencesemail-preferences.centerShow the preference center form
POST/email-preferencesemail-preferences.center.saveSave 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:

app/Mail/NewsletterMail.php
<?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

MethodURIRoute NameDescription
GET/email-preferences/unsubscribeemail-preferences.unsubscribeShow unsubscribe confirmation page
POST/email-preferences/unsubscribeemail-preferences.unsubscribe.postRFC 8058 one-click unsubscribe endpoint
All unsubscribe routes use Laravel's signed URLs, so they cannot be tampered with or guessed. The signature is validated before any preference change is made.

Generate an unsubscribe URL manually

PHP
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:

app/Notifications/OrderShippedNotification.php
#[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.

You never call 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:

FrequencyDefault ScheduleCron
DailyEvery day at 08:000 8 * * *
WeeklyMonday at 08:000 8 * * 1

Make sure the Laravel scheduler is running:

crontab
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1

Dispatch a digest item manually

PHP
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:

Terminal
php artisan vendor:publish --tag=email-preferences-mailable

Then point config to your custom class:

config/email-preferences.php
'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:

.env
EMAIL_PREFERENCES_DIGEST_QUEUE=emails


Events

The package fires events at each key lifecycle moment. Listen to them in your EventServiceProvider or using Laravel's #[Listen] attribute.

EventFired whenPayload
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

app/Providers/EventServiceProvider.php
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.

Terminal
# 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

๐Ÿš€
Installing on an existing app

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.

โž•
Adding a new category after launch

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.

๐Ÿ”„
Resetting preferences to defaults

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.

Terminal
# 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

OptionDefaultDescription
--modelApp\Models\UserFully-qualified notifiable model class to seed
--frequencyinstantDefault frequency for frequency-controlled categories
--forceโ€”Overwrite preference rows that already exist

API Reference

Complete method reference for the HasEmailPreferences trait.

prefersEmail(string $category): bool

Returns true if the user is subscribed and their frequency is not never.

emailFrequency(string $category): string

Returns the user's current frequency for the given category: 'instant', 'daily', 'weekly', or 'never'.

subscribe(string $category, string $via = 'api'): void

Subscribe the user to a category and log the consent action.

unsubscribe(string $category, string $via = 'api'): void

Unsubscribe the user from a category and log the action. Has no effect on required categories.

setEmailFrequency(string $category, string $frequency, string $via = 'api'): void

Set 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): bool

Check whether the user was subscribed to a category at a specific point in time. Queries the immutable audit log.

lastConsentFor(string $category): ?EmailPreferenceLog

Get the most recent consent log entry for a category. Returns null if no record exists.

emailPreferences(): MorphMany

Eloquent relationship to all EmailPreference records for this model.

emailPreferenceLogs(): MorphMany

Eloquent relationship to all EmailPreferenceLog audit records for this model.


Database Schema

email_preferences

ColumnTypeDescription
idbigint PKAuto-increment primary key
notifiable_typestringPolymorphic model class name
notifiable_idbigintPolymorphic model ID
categorystringCategory key (e.g. marketing)
frequencystringinstant | daily | weekly | never
unsubscribed_attimestamp?Set when unsubscribed, null when subscribed
created_attimestamp
updated_attimestamp

Unique index on (notifiable_type, notifiable_id, category).

email_preference_logs Immutable

ColumnTypeDescription
idbigint PK
notifiable_typestringPolymorphic type
notifiable_idbigintPolymorphic ID
categorystringCategory key
actionstringsubscribed | unsubscribed | frequency_changed
viastringSource of the change (e.g. api, preference_center)
ip_addressstring?Up to 45 chars (supports IPv6)
user_agenttext?Browser/client user agent string
created_attimestampIndexed. No updated_at โ€” rows are never modified.

pending_digest_items

ColumnTypeDescription
idbigint PK
notifiable_typestringPolymorphic type
notifiable_idbigintPolymorphic ID
categorystringCategory key
frequencystringdaily | weekly
typestringItem type identifier (e.g. order_shipped)
payloadjsonSerialized MailMessage data
created_attimestamp
updated_attimestamp

Routes Reference

All package routes are registered under the web middleware group and protected by Laravel's signed URL verification.

MethodURIRoute NameDescription
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
All routes require a valid Laravel signed URL signature. Requests without a valid signature receive a 403 response.