Scheduling Laravel notifications using Snooze

November 3, 2019

Code Example

Laravel's abstraction of notifications is awesome and makes sending messages across plenty of different channels quick and simple, but there's no good way to schedule a notification in the future.

I often end up with various notifications that need to be sent at a specific time, for example, a booking reminder or upcoming invoice reminder. Most of the time, I end up adding remind_at and reminder_sent or similar fields to the entity and have a scheduled task that runs a query & sends the applicable notifications. This works fine, but when you have a lot of scheduled notifications it gets messy pretty quickly.

Wouldn't it be nice to just say notifyAt similar to how we already say notify and then not have to worry about the notification ever again?

Thomas & I pretty quickly figured out that we both had similar issues with scheduling across codebases we've worked on, so we built Snooze.

Using Snooze

Our ethos when building Snooze was making it easy to use. Snooze works pretty much exactly the same as standard notifications, except that they are persisted to your database and delivered once the sendAt date/time passes.

Snooze works out of the box. Just run the migrations, add our SnoozeNotifiable trait to any of your notifiable objects and start scheduling.

composer require thomasjohnkane/snooze # Install Package
php artisan migrate # Run migrations
use Thomasjohnkane\Snooze\Traits\SnoozeNotifiable;

class User extends Model {
    use Notifiable;
    use SnoozeNotifiable;

    // ...
}

// Send an invoice reminder a week before it's due
$user->notifyAt(
    new UpcomingInvoiceNotification($invoice), 
    $invoice->due_at->subDays(7)
);

// Schedule a birthday notification
$user->notifyAt(new BirthdayNotification(), $user->birthday);

Interrupting scheduled notifications

What happens if we schedule an invoice payment reminder, but the user pays the invoice before the notification is to be sent?

To stop an email from being sent conditionally, you can add a shouldInterrupt() method to the notification. This method will be checked immediately before the notification is sent, and if it returns true the notification won't be sent.

public function shouldInterrupt($notifiable) {
    return $this->invoice->is_paid;
}

What if a user cancels their accounts? You don't want any of their scheduled emails to be sent as they are no longer a customer.

In this case, you can pass your Notifiable model to cancelByTarget and any unsent scheduled notifications for that specific model will be cancelled.

If you'd like more control over what's cancelled, you can use findByTarget which returns a collection of the scheduled notifications for that target & cancel specific notifications.

$user->cancel(); // Cancel account

class User extends Model {
    // ...

    public function cancel() 
    {
        // ... other logic
        // Cancel all scheduled notifications for the user
        ScheduledNotification::cancelByTarget($user);
    }
}

Under the Hood

Creating Scheduled Notifications

When you call notifyAt() on a model, it proxies the arguments & the model to ScheduledNotification::create which handles scheduling and persists the notification to the database.

First off, we do some basic checks to ensure the provided objects are valid. This includes ensuring the passed target is actually notifiable by checking that it has a notify method (there's no contract that notifiable objects implement).

Since we are dealing with models ( the Notifiable model & properties on the notification may contain models) we can't just serialize the object directly. If we did, when trying to send the notification at a later date we'd end up using the stale data from when the notification was originally scheduled.

To get around this, we use the SerializesAndRestoresModelIdentifiers trait provided by Laravel which returns a serialized version containing the model identifiers instead of the actual model data. We also save some metadata about the target model so we can query it later if we need to find notifications for a specific model.

if ($sendAt <= Carbon::now()->subMinute()) {
    throw new SchedulingFailedException(sprintf('`send_at` must not be in the past: %s', $sendAt->format(DATE_ISO8601)));
}

if (!method_exists($notifiable, 'notify')) {
    throw new SchedulingFailedException(sprintf('%s is not notifiable', get_class($notifiable)));
}

$modelClass = self::getScheduledNotificationModelClass();

$targetId = $notifiable instanceof Model ? $notifiable->getKey() : null;
$targetType = $notifiable instanceof AnonymousNotifiable ? AnonymousNotifiable::class : get_class($notifiable);

return new self($modelClass::create([
    'target_id' => $targetId,
    'target_type' => $targetType,
    'notification_type' => get_class($notification),
    'target' => Serializer::create()->serializeNotifiable($notifiable),
    'notification' => Serializer::create()->serializeNotification($notification),
    'send_at' => $sendAt,
]));

Sending Scheduled Notifications

Snooze adds a scheduled job that runs every minute, checking if there are any notifications that are ready to be sent. Because it's possible that some schedule runs may get missed (for example, during maintenance or downtime) we use a configurable "Send Tolerance" (defaults to 24 hours) to catch missed notifications.

Once we've got a collection of notifications that are ready to send, we loop over them, calling the send() method.

Inside the send method, we perform a few sanity checks, then unserialize the target Notifiable model & notification.

Before we actually send the notification, we check if the notification has a shouldInterrupt method. If it does, we execute it to determine if the notification should still be sent and cancel it if the method returns true.

Once the notification is sent, we fire a NotificationSent event (which the programmer can hook into, for example, to keep metrics on scheduled notifications).

The notification is then marked as sent, ensuring that Snooze won't try to resend it again.

if ($this->cancelled_at !== null) {
    throw new NotificationCancelledException('Cannot Send. Notification cancelled.', 1);
}

if ($this->sent_at !== null) {
    throw new NotificationAlreadySentException('Cannot Send. Notification already sent.', 1);
}

$notifiable = $this->serializer->unserializeNotifiable($this->target);
$notification = $this->serializer->unserializeNotification($this->notification);

if ($this->shouldInterrupt($notification)) {
    event(new NotificationInterrupted($this));

    return;
}

$notifiable->notify($notification);

event(new NotificationSent($this));

$this->sent_at = Carbon::now();
$this->save();

Wrapping Up

We're really excited to have released v1.0 of Snooze and are looking forward to hearing your reactions. We're already using it in several production applications and have been really happy with the results. Hopefully, Snooze will save you as much time and frustration as it has saved us!

$laravelCommunity->notifyAt(
    new CoolNewPackageNotification(), 
    CarbonImmutable::now()
);