Using Laravel's native rate limiter to avoid flooding users with duplicate notifications.

Laravel Notifications allow you to easily send users notifications across different channels, including email and SMS. It’s a powerful and incredibly useful feature, but depending on your application you may need to limit the amount of notifications your users receive.

We recently had this exact request from a client. Their application notifies users, across multiple teams, when a product is updated – the notifications themselves are fairly generic and are intended to just give the user a little 'nudge' to visit the product page to see the updates.

Under the hood, the application uses events and event listeners to determine when it needs to send a notification.

Whenever a user updates a product, we fire an ProductUpdated event. A separate SendProductNotifications listener is responsible for sending out the ProductUpdated notifications when it hears the ProductUpdated event.

The event carries data about the product that was updated; using this data the listener can work out which users need notifying. In this case, this is essentially all the administrators of Hubs that have access to a Product.

The issue we had was that users could make (and were making) multiple, minor updates to products in a short space of time, which caused administrators to get notified multiple times about updates to the same project. This was compounded by the fact that a user could be an administrator of multiple Hubs.

It was at this point we started to look at how we could rate limit the amount of 'product updated' notifications a user received within a specified timeframe.

A bit like Login Throttling, right?!

The first thing that came to mind when mapping out some solutions to our problem was Laravel's Login Throttling feature. Here's how it is described in the Laravel documentation:

If you are using Laravel's built-in LoginController class, the Illuminate\Foundation\Auth\ThrottlesLogins trait will already be included in your controller. By default, the user will not be able to login for one minute if they fail to provide the correct credentials after several attempts. The throttling is unique to the user's username / e-mail address and their IP address.

This sounded like what we needed, but instead of throttling login attempts based on a user’s username, e-mail and IP address, we wanted to throttle Notifications based on the notification type, a user's ID, and the updated product's ID.

Leveraging Laravel’s RateLimiter class

As mentioned in the documentation, Laravel provides its login throttling logic through the Illuminate\Foundation\Auth\ThrottlesLogins trait. Looking at the trait we could determine what we would need to implement to achieve our goal:

We would need to use Illuminate\Cache\RateLimiter to keep track of the amount of notification attempts. Specifically its hit() and tooManyAttempts() methods, both of which require:

  1. a string key you want to use to identify an attempt
  2. the decay seconds – the number of seconds to throttle for

tooManyAttempts() also requires us to pass through the maximum amount of attempts that should be allowed, which in our case will always be 1.

Now that we knew how we’d manage the rate limiting, we needed to work out what to change in order to insert that logic into Laravel's native 'send notification' behaviour.

Customising Notifiable

As per the official Notifications documentation we were already using the Notifiable trait on our User model. The trait is composed of two additional traits:

  1. HasDatabaseNotifications - adds the methods needed to pull notifications from the database
  2. RoutesNotifications - adds the notify() method thats used to send a given notification

Routing Throttled Notifications

Implementing our own notify method was pretty simple. We created two new traits:

  1. App\Notifications\RoutesThrottledNotifications – where our customised notify() method would live
  2. App\Notifications\Notifiable – a copy of the Illuminate\Notifications\Notifiable trait, that used our new RoutesThrottledNotifications trait.

Here's our App\Notifications\Notifiable trait:

<?php

namespace App\Notifications;

use Illuminate\Notifications\HasDatabaseNotifications;

trait Notifiable
{
    use HasDatabaseNotifications, RoutesThrottledNotifications;
}

App\Notifications\RoutesThrottledNotifications looks like this:

<?php

namespace App\Notifications;

use Illuminate\Support\Str;
use Illuminate\Cache\RateLimiter;
use Illuminate\Support\Facades\Log;
use Illuminate\Notifications\RoutesNotifications;

trait RoutesThrottledNotifications
{
    // Include the original trait, but map the notify method to another name...
    use RoutesNotifications {
        RoutesNotifications::notify as parentNotify;
    }

    // Our customised method...
    public function notify($instance)
    {
        // Here we check whether the Notification is an instance of a new
        // ThrottledNotification interface. The interface itself ensures
        // that certain methods are available on the notification.
        if ($instance instanceof ThrottledNotification) {
            // Get the throttle key for the given Notification.
            $key = $this->throttleKey($instance);

            // Use the key to check whether there have been too many attempts...
            if ($this->limiter()->tooManyAttempts($key, $this->maxAttempts())) {
                // It's up to you what you do here. We're logging the skipped
                // notifications.
                return Log::notice("Skipping sending notification with key `$key`. Rate limit reached.");
            }

            // The attempt was OK, so we increment the limiter, passing through
            // the decay seconds that the ThrottledNotification interface
            // demands to be set the Notification that implements it.
            $this->limiter()->hit($key, $instance->throttleDecaySeconds());
        }

        // Execute the original notify() method.
        $this->parentNotify($instance);
    }

    /**
     * Get the rate limiter instance.
     */
    protected function limiter()
    {
        return app(RateLimiter::class);
    }

    /**
     * Build the notification throttle key from the Notification class name,
     * the Notification's throttle key id and the current users id.
     *
     * Output example: productupdated|1|10
     */
    protected function throttleKey($instance)
    {
        return Str::lower(
            class_basename($instance) . '|' . $instance->throttleKeyId() . '|' . $this->getAuthIdentifier()
        );
    }

    /**
     * Set the max attempts to 1.
     */
    protected function maxAttempts()
    {
        return 1;
    }
}

You'll notice that we determine which notifications should be rate limited using the ThrottledNotification interface. This ensures that each Notification that implements the interface will have its own throttleDecaySeconds() and throttleKeyId() methods defined.

Here's the full interface:

<?php

namespace App\Notifications;

interface ThrottledNotification
{
    /**
     * Get the throttle key id for the given notification.
     *
     * @return string
     */
    public function throttleKeyId();

    /**
     * Get the number of seconds to throttle for.
     *
     * @return int
     */
    public function throttleDecaySeconds();
}

...and it applied to our ProductUpdate notification:

<?php

namespace App\Notifications\Product;

use App\Products\Product;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Notifications\ThrottledNotification;
use App\Notifications\Channels\DatabaseChannel;
use App\Mail\Product\ProductUpdate as ProductUpdateMail;

class ProductUpdate extends Notification implements ThrottledNotification
{
    use Queueable;

    private $product;

    public function __construct(Product $product)
    {
        $this->product = $product;
    }

    public function via($notifiable)
    {
        return ['mail', DatabaseChannel::class];
    }

    public function toMail($notifiable)
    {
        return (new ProductUpdateMail($this->product->owner(), $this->product))->to($notifiable->email);
    }

    public function toArray($notifiable)
    {
        // ...
    }

    /**
     * Get the throttle key id for the given notification.
     *
     * @return string
     */
    public function throttleKeyId()
    {
        return $this->product->id;
    }

    /**
     * Get the number of seconds to throttle for.
     *
     * @return int
     */
    public function throttleDecaySeconds()
    {
        return 60;
    }
}

Once our notification implemented ThrottledNotification and our 'notifiable' used our custom Notifiable trait we didn't need to update our code elsewhere as the native $user->notify() API was unchanged. 🎉

Overall, this approach has allowed us to leverage code within the Laravel codebase and add a useful feature without having to modify much of the existing site code at all.


Note: James Mills has put together a handy package based on this approach to rate limiting notifications. You can find it on GitHub.