Opting Out: A Simple Solution for Conditionally Cancelling Laravel Notifications

The average Laravel app, depending on its size, will typically send somewhere between a few and a few dozen notifications to users as they use the app. I recently worked on a project that was notifying each user dozens of times, and I quickly found myself asking: "What if users don't want all these notifications?"

With legal regulations such as GDPR making it more important than ever to give users control over the emails they receive, I wanted to create an elegant solution that would allow users to opt out of each notification at a granular level.

A Basic Approach

Your initial instinct when approaching this task may be to simply not send the notification at all. That would mean turning a line of code like this, maybe in a controller:

auth()->user()->notify(new ExampleNotification);

Into something like this:

if ($some_condition_has_been_met) {
    auth()->user()->notify(new ExampleNotification);
}

A simple conditional certainly does the job if you're sending one or two notifications, but it can start to feel brittle as you begin sending 10 or 15 or more. This is particularly true if the logic to opt out is similar across all notifications, or if the same notification may be sent from multiple places. Suddenly your conditional logic has been duplicated throughout the codebase.

The Ideal Scenario

As I thought about it, I planned my ideal scenario: to always dispatch the notification, and let it figure out for itself whether or not it should be sent. To do that, I had to ask myself the first big question: is it even possible to cancel a notification after it's been dispatched?

Let's take a look at the boilerplate Notification class generated by running php artisan make:notification (empty methods have been removed for the sake of clarity):

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class ExampleNotification extends Notification
{
    use Queueable;

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

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('The introduction to the notification.')
                    ->action('Notification Action', url('/'))
                    ->line('Thank you for using our application!');
    }
}

Every notification comes with two primary methods. First, via, which returns an array of channels you'd like the notification to be delivered through. For most of us, this is typically just set to mail, although other channels like Slack or SMS are common as well.

Then, we have the toMail method, which is responsible for building a MailMessage for the email.

My initial thought upon examining this class was to try a deeper dive into Laravel's base notification class (which each notification extends.) There must be a terminate or cancel method you could override, right? Turns out, there's not.

I was dismayed, but not defeated. So I took another look at the via method and noticed that, while it defaults to ['mail'], it could theoretically be set to an empty array. I wondered, "if it had no delivery channels, would it just be delivered nowhere?" A quick test of sending a notification via Tinker confirmed my suspicion: notifications with no channels will simply not be sent.

The solution felt imperfect but workable. I knew with a little effort I could make it feel like the original solution I had been looking for. It was time to get to work.

The first thing I would need is a singular place to override the via method on all notifications that could be cancelled. So I created a trait called OptOutable, and gave it its own via method:

namespace App;

trait OptOutable
{
    public function via($notifiable)
    {
        return ['mail'];
    }
}

Next, I added an optOut method to the trait. If the method returned true, the via method on the trait would return an empty array, thus sending no notifications. If false, it would return the standard default channels.

namespace App;

trait OptOutable
{
    public function via($notifiable)
    {
        if ($this->optOut($notifiable)) {
            return [];
        }

        return ['mail'];
    }

    public function optOut($notifiable)
    {
        return false;
    }
}

Note: If you had some kind of global "opt-out of all notifications" setting, the optOut method on the OptOutable trait would be a perfect place to put it.

With the trait setup, I now just had to update my notification to include it:

namespace App\Notifications;

use App\OptOutable;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class ExampleNotification extends Notification
{
    use OptOutable, Queueable;

    public function optOut($notifiable)
    {
        // Insert opt-out logic that returns a boolean
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('The introduction to the notification.')
                    ->action('Notification Action', url('/'))
                    ->line('Thank you for using our application!');
    }
}

Note: You'll notice the via method has been removed from our notification entirely. This is important because if it remained it would override the via method on our trait that handles the opt-out.

Now, an optOut method that receives the $notifiable is available on every notification using the trait, giving you and your users granular control over each notification. The logic inside the optOut method will depend on your project's domain. For instance, if your $notifiable is a User model and the users table has an "opted_out_of_notifications" boolean column, the optOut method might look like this:

public function optOut($notifiable)
{
    return $notifiable->opted_out_of_notifications;
}

Changing the Channel

One downside to this method is that, because the default channels are hardcoded inside of our trait, you can't modify them per notification. So if you want one notification to be sent via email, and another via text, you're out of luck.

Thankfully, there's a quick fix. By adding a public $channels property to our trait set to the defaults and having our via method return it, you can regain control of your delivery channels per notification:

namespace App;

trait OptOutable
{
    public $channels = ['mail'];

    public function via($notifiable)
    {
        if ($this->optOut($notifiable)) {
            return [];
        }

        return $this->channels;
    }

    public function optOut($notifiable)
    {
        return false;
    }
}

Now, changing your delivery channel within a notification is simple:

namespace App\Notifications;

use App\OptOutable;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class ExampleNotification extends Notification
{
    use OptOutable, Queueable;

    public $channels = ['slack'];

    public function optOut($notifiable)
    {
        // Insert opt-out logic that returns a boolean
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('The introduction to the notification.')
                    ->action('Notification Action', url('/'))
                    ->line('Thank you for using our application!');
    }
}

Have you ever had to build a more robust notification system? Tweet me at @imjohnbon or @tightenco, I'd love to hear about it!