Syropia
Published on November 11, 2023

Throttling notification channels in Laravel

This post was last updated on November 26, 2023

When sending notifications through multiple channels (say email and SMS), you may want to throttle the number of notifications sent through a specific channel. For example, you could limit the number of SMS notifications sent to a user to 1 per day, and limit emails to 5 per day. I’m going to show you how we can leverage Laravel’s RateLimiter class to achieve this with only a few lines of code.

Note

If you’re interested in this functionality, but don’t want to maintain it yourself, I’ve built a package that does all the hard work for you. Feel free to leave a ⭐️ if you find it useful!

Setting up our notification

First let’s set up an example notification class. We’ll use the notion of a user favouriting a post as our example. We’ll send a notification to the post’s author when a user favourites their post.

php artisan make:notification PostFavouritedNotification

In this example we also want to store a copy of the notification in the database. Let’s modify the via method in your notification class to define two channels — mail and database

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

Since we’re using the database channel, we should ensure we have the table setup in our own database correctly. Fortunately, Laravel provides a migration for this out of the box. Let’s run it now.

php artisan notifications:table
php artisan migrate

Throttling channels

In our example, we want to throttle the number of notifications sent through the mail channel to one every hour. So how can we do this? We’ll lean on both Laravel’s event system and its built-in RateLimiter class.

Listening for notification events

When Laravel sends a notification, it first dispatches a NotificationSending event. This event has a special power, and I’ll explain it through this excerpt taken directly from the Laravel documentation:

The notification will not be sent if an event listener for the NotificationSending event returns false from its handle method.

Interesting! This means we can control if this notification gets sent out by simply returning true or false in an event listener. Let’s go ahead and create that listener class now.

php artisan make:listener CheckNotificationRateLimiter

You should have something like so:

<?php

namespace App\Listeners;

class CheckNotificationRateLimiter
{
    public function handle($event)
    {
    }
}

Let’s type our $event argument to be of type NotificationSending. We can do this by importing the class at the top of the file, and adding it as a type hint to the handle method. We know we’re going to return a bool here so we’ll set that as the return type as well.

// ...
use Illuminate\Notifications\Events\NotificationSending;

class CheckNotificationRateLimiter
{
    public function handle(NotificationSending $event): bool
    {
    }
}

This event object has some very useful properties. We can access the notification instance, the notifiable instance, and the channel the notification is being sent through. Let’s use this to our advantage.

We know that if the channel is database, we always want to send the notification. So we’ll return true if that’s the case.

public function handle(NotificationSending $event)
{
    if ($event->channel === 'database') {
      return true;
    }
}

Ok, we can finally set up our rate limiting for the mail channel! First we’ll construct a unique key that we can use to identify this notification. We’ll use the fully qualified class name of the notification, the channel, and the notifiable’s ID. We’ll use the implode function to join these values together with a colon.

public function handle(NotificationSending $event)
{
    if ($event->channel === 'database') {
        return true;
    }

    $key = implode(":", [$event->notification::class, $event->channel, $event->notifiable->id]);
}

Next we’ll use the tooManyAttempts method on the RateLimiter facade and pass our key, plus a maxAttempts value of 1. This will return true if the user has exceeded the maximum number of attempts. In our case we want to return the negated value of this call, as we want to return true if the user has not exceeded the maximum number of attempts.

// ...
use Illuminate\Notifications\Events\NotificationSending;
use Illuminate\Support\Facades\RateLimiter;

public function handle(NotificationSending $event): bool
{
    if ($event->channel === 'database') {
        return true;
    }

    $key = implode(":", [$event->notification::class, $event->channel, $event->notifiable->id]);

    return !RateLimiter::tooManyAttempts(
        key: $key,
        maxAttempts: 1,
    );
}

Perfect! So how does the RateLimiter know when we’ve made too many attempts? Well, we need to increment the limiter each time we send a notification. Similar to the NotificationSending event, Laravel also dispatches a NotificationSending after the notification was successfully sent. This sounds like a great place to do our incrementing as it is queue-friendly and will only run if the notification was successfully sent. Let’s create another listener class to handle this event.

php artisan make:listener HitNotificationRateLimiter

Similar to the CheckNotificationRateLimiter, we can typehint our $event parameter — this time using the Illuminate\Notifications\Events\NotificationSent class.

// ...
use Illuminate\Notifications\Events\NotificationSent;

class HitNotificationRateLimiter
{
    public function handle(NotificationSent $event)
    {
    }
}

All we need to do now is construct the same key we did in the previous listener, and call the hit method on the RateLimiter facade. Once again, if the channel is database, we don’t need to do anything, so we’ll return early.

//...
use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\RateLimiter;

public function handle(NotificationSent $event)
{
    if ($event->channel === 'database') {
      return;
    }

    $key = implode(":", [$event->notification::class, $event->channel, $event->notifiable->id]);

    RateLimiter::hit(
        key: $key,
        decaySeconds: 3600, // 1 hour
    );
}

Nice! The only thing left to do is wire up your listeners to their respective events. Go ahead and open up app/Providers/EventServiceProvider.php and add the following to the $listen array:

// ...
use Illuminate\Notifications\Events\NotificationSending;
use Illuminate\Notifications\Events\NotificationSent;
use App\Listeners\CheckNotificationRateLimiter;
use App\Listeners\HitNotificationRateLimiter;

//...
protected $listen = [
    NotificationSending::class => [
        CheckNotificationRateLimiter::class,
    ],
    NotificationSent::class => [
        HitNotificationRateLimiter::class,
    ],
];

Perfect! We’ve now successfully set up rate limting for our notification’s mail channel.

You may be thinking, “Ok great, but how do I configure different limiters for different notifications?“. You’ll recall that the event object passed from NotificationSending and NotificationSent has a notification property. This is the actual notification instance that is being sent. We can use this to to configure different limiters for different notifications. You could modify the CheckNotificationRateLimiter listener to do just that.

// ...
public function handle(NotificationSending $event)
{
    if ($event->notification::class === PostFavouritedNotification::class) {
        if ($event->channel === 'database') {
            return true;
        }

        $key = implode(":", [$event->notification::class, $event->channel, $event->notifiable->id]);
    } else if ($event->notification::class === SomeOtherNotification::class) {
        // return true/false
    }
    // ...
}

This will work just fine, but as you may have noticed, it’s not very scalable. If you have a lot of notifications, you’ll end up with a lot of conditional statements.

Refactoring to an interface

I think a nice API to handle this would be to define a throttle configuration directly on the notification class itself. Let’s build a quick contract class for this method. Go ahead and create a new interface called ThrottlesChannels. You can put it in the anywhere you like in the app directory, but I’ll put it in app/contracts.

<?php

namespace App\Contracts;

interface ThrottlesChannels
{
    public function throttleChannels(object $notifiable, array $channels): array;
}

We’ve defined a single method for this interface — throttleChannels. This method accepts the notifiable instance, and an array of channels. It should return an array of channels and their associated throttle configurations. Let’s implement this interface on our PostFavouritedNotification class.

use Illuminate\Notifications\Notification;
use App\Contracts\ThrottlesChannels;

class PostFavouritedNotification extends Notification implements ThrottlesChannels {
    // ...

    public function throttleChannels(object $notifiable, array $channels): array
    {
        /**
         * Throttle the mail channel, so that only one
         * email notification is sent every hour
         */
        return [
            'mail' => [
                'key' => (string) $notifiable->id,
                'maxAttempts' => 1,
                'decaySeconds' => 3600,
            ],
            'database' => false,
        ];
    }
}

Here we’ve returned a throttle configuration that satisfies our requirements. We want to throttle the mail channel to one notification per hour scoped to the notifiable instance, and we don’t throttle the database channel at all. We can now modify our CheckNotificationRateLimiter listener to use this method.

Let’s clear out the existing logic and start fresh. The first thing we’ll do is check if the notification class implements the ThrottlesChannels interface. If it doesn’t, we’ll return true to allow the notification to send.

// ...
use Illuminate\Notifications\Events\NotificationSending;
use App\Contracts\ThrottlesChannels;

public function handle(NotificationSending $event): bool
{
    if ($event->notification instanceof ThrottlesChannels) {
      // TODO: Implement me
    }

    return true;
}

If our notification does indeed implement the ThrottlesChannels interface we first need to get the notification’s available channels. We can do this by calling the via method on the notification instance. We’ll then pass these channels to the throttleChannels method on the notification instance. This will return an array of channels and their associated throttle configurations.

// ...
use Illuminate\Notifications\Events\NotificationSending;
use App\Contracts\ThrottlesChannels;

public function handle(NotificationSending $event): bool
{
    if ($event->notification instanceof ThrottlesChannels) {
        $channels = $event->notification->via($event->notifiable);

        $throttleConfig = $event->notification->throttleChannels($event->notifiable, $channels);

        $channelConfig = $throttleConfig[$event->channel];

        if (empty($channelConfig)) {
            return true;
        }
    }

    return true;
}

If there is no throttle configuration for a channel, or it’s value was set to false, we return true to allow the notification to send.

We’re going to construct a base key to use for the rate limiter, and we allow the key to be extended by passing a key property to the throttle configuration. It will consist of the fully qualified class name of the notification, and the channel name.

// ...
public function handle(NotificationSending $event): bool
{
    if ($event->notification instanceof ThrottlesChannels) {
        $channels = $event->notification->via($event->notifiable);

        $throttleConfig = $event->notification->throttleChannels($event->notifiable, $channels);

        $channelConfig = $throttleConfig[$event->channel];

        if (empty($channelConfig)) {
            return true;
        }

        $key = implode(":", [$event->notification::class, $event->channel]);

        if (! empty($channelConfig['key'])) {
            $key .= (string) $channelConfig['key'];
        }
    }

    return true;
}

Now all that’s left is to grab the maxAttempts value from the throttle configuration, and call the tooManyAttempts method on the RateLimiter facade.

// ...
public function handle(NotificationSending $event): bool
{
    if ($event->notification instanceof ThrottlesChannels) {
        $channels = $event->notification->via($event->notifiable);

        $throttleConfig = $event->notification->throttleChannels($event->notifiable, $channels);

        $channelConfig = $throttleConfig[$event->channel];

        if (empty($channelConfig)) {
            return true;
        }

        $key = implode(":", [$event->notification::class, $event->channel]);

        if (! empty($channelConfig['key'])) {
            $key .= (string) $channelConfig['key'];
        }

        $maxAttempts = $channelConfig['maxAttempts'] ?? 1;

        return ! RateLimiter::tooManyAttempts($key, $maxAttempts);
    }

    return true;
}

Now we can pop over to our HitNotificationRateLimiter listener and modify it to use the ThrottlesChannels interface as well.

<?php

namespace Syropian\LaravelNotificationChannelThrottling\Listeners;

use Illuminate\Notifications\Events\NotificationSent;
use Illuminate\Support\Facades\RateLimiter;
use App\Contracts\ThrottlesChannels;

class HitNotificationRateLimiter
{
    public function handle(NotificationSent $event)
    {
        if ($event->notification instanceof ThrottlesChannels) {
            $channels = $event->notification->via($event->notifiable);
            $throttleConfig = $event->notification->throttleChannels($event->notifiable, $channels);
            $channelConfig = $throttleConfig[$event->channel];

            if (empty($channelConfig)) {
                return;
            }

            $key = $event->notification::class.':'.$event->channel;

            if (! empty($channelConfig['key'])) {
                $key .= (string) $channelConfig['key'];
            }

            $decaySeconds = $channelConfig['decaySeconds'] ?? 1;

            RateLimiter::hit($key, $decaySeconds);
        }
    }
}

This is nearly identical to the CheckNotificationRateLimiter listener, except we’re calling the hit method on the RateLimiter facade. We’re also passing a decaySeconds value to the method, which will be used to determine how long the limiter should be active for.

That’s it! Beautifully simple notification channel throttling, just by implementing a single interface.

Bonus round — testing

So what does the testing story look like here? Fortunately Laravel provides us with pretty much everything we need to easily test this functionality. Let’s start by creating a test called ThrottlesChannelsTest. For this example I’m going to be using Pest, but this will work just fine with PHPUnit as well.

php artisan make:test ThrottlesChannelsTest --pest

Ok let’s get started. First we’ll set up some factory data so we have the required entities to send the notification.

<?php

use App\Models\User;
use App\Models\Post;

beforeEach(function () {
    $this->author = User::factory()->create();
    $this->user = User::factory()->create();
    $this->post = Post::factory()->create(['user_id' => $this->author->id]);
});

Now lets write a test to confirm that our channels are throttled how we expect. The first thing we will do is fake Laravel’s built-in MessageSent event. Here’s another excerpt straight from the Laravel documentation:

Laravel fires two events during the process of sending mail messages. The MessageSending event is fired prior to a message being sent, while the MessageSent event is fired after a message has been sent.

Cool! What we’ll do now is fake the MessageSent event so we can perform assertions on it after sending out notifications.

use Illuminate\Support\Facades\Event;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Notifications\DatabaseNotification;
// ...
it('throttles notification channels', function () {
    Event::fake([
        MessageSent::class,
    ]);
});

Now we’ll send two notifications back-to-back. We’ll assert that the MessageSent event was only called once, and also assert that there are two DatabaseNotification records in the database.

use Illuminate\Support\Facades\Event;
use Illuminate\Mail\Events\MessageSent;
// ...
it('throttles notification channels', function () {
    Event::fake([
        MessageSent::class,
    ]);

    $this->author->notify(new PostFavouritedNotification($this->post, $this->user));
    $this->author->notify(new PostFavouritedNotification($this->post, $this->user));

    Event::assertDispatched(MessageSent::class, 1);
    expect(DatabaseNotification::count())->toBe(2);
});

If we go ahead and run these tests they should be passing. Nice! However, we should also test that after 1 hour, another mail notification will indeed be sent. Fortunately Laravel comes with a travel method that allows us to travel to a specific time. Let’s see what that looks like.

use Illuminate\Support\Facades\Event;
use Illuminate\Mail\Events\MessageSent;
// ...
it('throttles notification channels', function () {
    Event::fake([
        MessageSent::class,
    ]);

    $this->author->notify(new PostFavouritedNotification($this->post, $this->user));
    $this->author->notify(new PostFavouritedNotification($this->post, $this->user));

    Event::assertDispatched(MessageSent::class, 1);
    expect(DatabaseNotification::count())->toBe(2);

    // Travel 1 hour in the future and send another notification
    $this->travel(1)->hours();

    $this->author->notify(new PostFavouritedNotification($this->post, $this->user));

    Event::assertDispatched(MessageSent::class, 2);
    expect(DatabaseNotification::count())->toBe(3);
});

How easy is that? As a best practice we should probably reset the test time back to default after each test runs. We can do this by calling the travelBack method inside an afterEach hook.

// ...
afterEach(function () {
    $this->travelBack();
});
// ...

If you made it this far, thanks for reading! I hope you found this article useful. If you have any questions or comments, feel free to reach out to me on Twitter.