Laravel Notifications: dynamic channels, priority, and delayed sending

Couple of days ago, I opened my account preferences in Linear, looking to customize how I receive email notifications. I saw something interesting, which is the following settings toggle on the Notifications page:

Delay low priority emails outside of work hours until the next work day

I found this pretty interesting, and I never really thought about it: in a B2B application that users use during work, it's a good idea to avoid sending emails to users outside of their work hours. In case of Linear, imagine your team member in a totally different timezone assigns you to an issue when it's 7PM for you... you shouldn't be bothered via email, at least not until you start your work day.

I could apply this to my context, so I spent some time recreating something similar using Laravel's built-in notification system. Here's what I was looking to eventually accomplish:

  1. Every notification is recorded in the database and displayed in the application UI, without the ability to get silenced/muted/disabled. This will be a good old database channel.
  2. Users can customize which low-priority notifications they wish to receive via email, and cannot silence high-priority notifications.
  3. Users can enable delayed low-priority sending — a feature where we'll not send low-priority notifications via email until the start of the next work day.
  4. Users should not receive an email for a notification if they previously saw/read the same in-app notification.
  5. Users shouldn't be bombarded with emails at 9AM. They should receive only one "summary" email containing every notification sent after their previous work day ended.
  6. API for sending notifications shouldn't change, and it should be seamless to add a new notification class.

Dynamic notification channels

To set the scene, let's do a basic setup where users can toggle email preference on and off. We'll add the email_notifications column to the users table that holds this information, and dynamically fetch notification channels based on that preference. It's up to you to hook this into your frontend to let the users toggle these options.

use App\Notifications\IssueAssigned;

class User extends Model
{
    public static function booted(): void
    {
        static::creating(function (self $user) {
            $user->email_notifications = [
                IssueAssigned::class => true,
            ];
        });
    }

    protected function casts(): array
    {
        return [
            'email_notifications' => 'array',
        ];
    }
}

We know for a fact that:

  • all notifications will be sent from a queue (duh, that should be the default)
  • for every user notification, we'll generate delivery channels on-demand

For that reason, we'll create an abstract class called something like UserNotification, that all our user notifications will extend, and define the initial via() method.

abstract class UserNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(User $notifiable): array
    {
        $wantsEmail = $notifiable->email_notifications[$this::class] ?? true;

        return $wantsEmail
                ? ['mail', 'database']
                : ['database'];
    }
}

Now we have a very basic setup in place. Users can customize how they want to be notified, and we know how to route them based on user's preferences. Next, we'll figure out notification priority.

Notification priority

In an issue-tracking application like Linear, you can differentiate between the two types of notifications based on their priority — high and low. For example:

  • High-priority: A person has joined your organization
  • Low-priority: You have been assigned to an issue

High-priority notifications should always be sent via email, they should be displayed in the application UI, and there should be no way for users to silence them — if a person joined our organization, owner must be notified of it.

We can override the via() method in every high-priority notification class to manually specify channels, but there's a more elegant solution: interfaces. We'll create an empty interface that every high-priority notification class will implement.

interface HighPriorityNotification
{
    //
}

class UserJoined extends UserNotification implements HighPriorityNotification
{
    //
}

class IssueAssigned extends UserNotification
{
    //
}

We now have a way of determining which notifications are always delivered via email. We can customize our logic for determining the delivery channels based on whether the notification class implements this interface. This is a common pattern you see in Laravel internals, particularly around queues, with interfaces such as ShouldQueue and ShouldBeUnique.

abstract class UserNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function via(User $notifiable): array
    {
        if ($this instanceof HighPriorityNotification) {
           return ['mail', 'database'];
        }

        $wantsEmail = $notifiable->email_notifications[$this::class] ?? true;

        return $wantsEmail
                ? ['mail', 'database']
                : ['database'];
    }
}

At this point, our application knows how to differentiate notifications by their priority, and we properly determine the delivery channels. Now, let's actually get to the core of this article, which is delayed sending.

Delayed sending of emails

Like I already mentioned, the goal is to avoid sending low-priority notifications via email outside of the regular work hours of the user. We need to intercept when the notification is being sent via the email channel, and do something with it.

Let's add a column that we'll need to the users table - delay_low_priority_notifications. This column will mimic the same functionality in Linear — user can toggle this on or off.

Schema::table('users', function (Blueprint $table) {
    $table->boolean('delay_low_priority_notifications')->default(true);
});

class User extends Model
{
    protected function casts(): array
    {
        return [
            'email_notifications' => 'array',
            'delay_low_priority_notifications' => 'boolean',
        ];
    }
}

In our application, every user has a timezone column, and we'll assume work hours are Monday to Friday from 9AM to 5PM. It's up to you to figure out what's best for you.

How do we dynamically delay email sending like that? Well, my first idea was to add withDelay() method to the Notification class. Here's a link to relevant Laravel documentation on this topic. Sweet! In that method, we can just delay until the next work day. However, there is a catch. The problem with just straight-up delaying emails is that our users will potentially start receiving tons of emails at 9AM the next morning. User would not be happy to get bombarded with emails, and our email provider certainly wouldn't like this.

Notification summary

Our implementation goal is to send one summary email with all notifications that have happened since their previous work day. If you think about it, this is essentially a new notification that's only sent via email channel. And we'll treat it like so — we'll create a new notification called NotificationSummary, and in it, we'll retrieve every notification that has happened since it's first queue time, and render them.

Important: we will not send the summary if there are no notifications that user needs to be aware of. This can happen if the user has read all notifications in the meantime.

class NotificationSummary extends Notification
{
    public function __construct(
        public Carbon $queuedAt,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function shouldSend(object $notifiable, string $channel): bool
    {
        return $this->notifications($notifiable)->isNotEmpty();
    }

    private function notifications(User $notifiable): Collection
    {
        $receivableNotifications = collect($user->email_notifications)->filter()->keys();

        // Only grab unread notifications created after X timestamp,
        // but only those that user wants to receive emails for...
        return $notifiable->unreadNotifications()
                    ->whereIn('type', $receivableNotifications)
                    ->where('created_at', '>=', $this->queuedAt)
                    ->get();
    }

    public function toMail(object $notifiable): MailMessage
    {
        //
    }
}

We now have a summary class, so let's hook it up. We'll implement the shouldSend() method in our UserNotification to determine whether the notification needs to be delayed until the next day, and we'll just send our summary notification with a delay. This is a method provided by Laravel: relevant docs.

There are a few paths:

  • in-app or high-priority should always be sent
  • do not delay if user wishes to receive them
  • if it's a weekend, send on Monday at 9AM
  • if it's a work day sent after 5PM, send on the next work day at 9AM
  • if it's sent before a work day starts (e.g. 3AM), send on the same work day at 9AM

Let's modify our shouldSend() method to cover these edge-cases, and send our notification summary with a delay:

// In UserNotification.php
public function shouldSend(object $notifiable, string $channel): bool
{
    // Always send in-app (database) notifications...
    if ($channel === 'database') {
        return true;
    }

    // High-priority notifications should always be sent...
    if ($this instanceof HighPriorityNotification) {
        return true;
    }

    // If the user does not want delayed notifications, send email notifications right away...
    if (! $notifiable->delay_low_priority_notifications) {
        return true;
    }

    $now = now($notifiable->timezone);
    $start = $now->copy()->setTimeFromTimeString('09:00');
    $end = $now->copy()->setTimeFromTimeString('17:00');

    $delayedUntil = match (true) {
        $now->isWeekend(), $now->gte($end) => $now->nextWeekday()->setTimeFromTimeString('09:00'),
        $now->lt($start) => $start,
        default => null, // within business hours (09:00–16:59)
    };

    if ($delayedUntil) {
        $notifiable->notify(
            (new NotificationSummary(now()))->delay($delayedUntil->copy()->setTimezone(config('app.timezone')))
        )

        return false;
    }

    return true;
}

With this method implemented, we'll send the summary the next time our user starts their work day. However, there's another catch. This will be triggered each time our user receives a notification outside of their work hours, and we'll send a new summary for every notification.

Single notification summary

That's not what we want — our goal is to only send a single summary email. At the time of writing this, Laravel doesn't support adding ShouldBeUnique interface to the notification class, but we can just wrap the notify() call in a job with the same interface. This interface is super handy for situations like these, because you can dispatch as many delayed jobs as you wish, and only one would be triggered — other jobs won't even be pushed to the queue if one instance is already in the queue.

class SendNotificationSummary implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(
        public Carbon $queuedAt,
        public User $user,
    ) {}

    public function handle(): void
    {
        $this->user->notify(new NotificationSummary($this->queuedAt));
    }

    public function uniqueId(): string
    {
        return 'notification-summary:'.$this->user->id;
    }
}
public function shouldSend(object $notifiable, string $channel): bool
{
    // Always send in-app (database) notifications...
    if ($channel === 'database') {
        return true;
    }

    // High-priority notifications should always be sent...
    if ($this instanceof HighPriorityNotification) {
        return true;
    }

    // If the user does not want delayed notifications, send email notifications right away...
    if (! $notifiable->delay_low_priority_notifications) {
        return true;
    }

    $now = now($notifiable->timezone);
    $start = $now->copy()->setTimeFromTimeString('09:00');
    $end = $now->copy()->setTimeFromTimeString('17:00');

    $delayedUntil = match (true) {
        $now->isWeekend(), $now->gte($end) => $now->nextWeekday()->setTimeFromTimeString('09:00'),
        $now->lt($start) => $start,
        default => null, // within business hours (09:00–16:59)
    };

    if ($delayedUntil) {
-       $notifiable->notify(
-           (new NotificationSummary(now()))->delay($delayedUntil->copy()->setTimezone(config('app.timezone')))
-       )
+       // Job uniqueness will ensure that only one summary email is sent per user per day...
+       SendNotificationSummary::dispatch(now(), $notifiable)->delay(
+           $delayedUntil->copy()->setTimezone(config('app.timezone')
+       );

        return false;
    }

    return true;
}

Summary

Think about what we just did. The first time our user receives a notification outside of their work hours, we'll intercept it and stop it from being delivered to their email inbox, but rather we'll push a job to the queue, indicating that there's a notification summary that needs to be sent at the start of a next work day, with respect to user's timezone.

We'll use job uniqueness to only schedule one job instance per user. In the notification summary, we'll do a one final check to see whether there are any notifications to be sent. If none (user has read them all), no point in sending an empty email. It's up to you to figure out how to render these notifications in the summary email.

And we haven't even touched our API. This was my goal — I wanted to use a good old $user->notify() and have delayed-sending just work. I think this implementation is pretty clean: notification doesn't know about when it's sent and how it's sent. It knows whether it's a high-priority or low-priority, and knows about how to render itself.