Skip to Main Content
Matts phone

Using Push Notifications with CraftCMS

What are push notifications?

Push notifications have been around for a few years now and exist as the browsers way of simulating a mobile app notification. When you receive a new message from a social media platform and their associated app notifies you, this is known as a push notification. Since 2015 a number of browsers have also enabled this functionality so that you can be notified by a site without the need to install a dedicated app. These notifications can appear on desktops, laptops, and tablets as well as mobile devices, so they aren’t just for a single target audience and can be used in many different ways. The push notifications can also lead to a url when clicked, or just display a simple message with no action attached.

Please be aware that if you are wanting to add this functionality for iOS devices, the code examples will need to be extended with your own custom code. You will need to use the library/documentation mentioned in the conclusion at the end of the article.

Give me some real world examples

You could allow notifications for stock alerts such as, x item is back in stock, order now. You could also wish to be notified when you receive a message/reply on a forum. An internal system used for tracking tasks could notify you when a job has been assigned to you, or you could simply be updated each time a new piece of content is published, such as a podcast or blog post.

Screenshot 20190829 150208

A push notification on a mobile device for an internal jobs system

All of the above examples are possible provided the recipient has subscribed to receive these notifications. Another great part of them is that you don’t need to enter any personal information, no phone number, no email address, it just registers a service worker within your browser to display the updates when they are pushed out. It’s easy for the users and secure as no identifiable data is being collected or held.

Best Practices

Most websites where I’ve seen push notifications being used are immediately displaying a prompt as soon as the page has loaded. I personally consider this to be bad practice. My reason for this is the same as when you are asked to review a site before you’ve been able to view it, you can’t reasonably answer the questions and it has gotten in the way of your browsing experience. For this same reason, we don’t yet know what we are allowing notifications for, so the majority of people are going to instantly dismiss/block them.

Best practice would mean that we trigger the popup prompt from a button on the page which a user has to intentionally click. This means that they have shown intent to be notified and they are much more likely to allow the notifications, they also know exactly what they will be notified about otherwise they wouldn’t have requested them by clicking the button.

Once a visitor has registered for push notifications, it's important we don’t bombard them with too many. We alos want to make sure each notification is relevant, for example we don’t want to notify them about changes which they themselves have made. A forum thread where you want to be notified of new messages demonstrates this point nicely. We don’t want a popup on our devices when we're the ones who have posted a new comment, this is a poor implementation if this happens. At the same time we also don’t want to keep notifying a person too frequently as it will just become an annoyance. This is much like an email newsletter, if you spam people too much, they will eventually unsubscribe.

Setting up the front end code

As I've mentioned above, we will be attaching the notification requests to a button click rather than displaying the prompt when the page has loaded. I’ve used jQuery in my example code but you are welcome to use whichever flavour of JS you are most comfortable with.

const check = () => {
   if (!('serviceWorker' in navigator)) {
       throw new Error('No Service Worker support!')
   }
   if (!('PushManager' in window)) {
       throw new Error('No Push API Support!')
   }
}

// urlB64ToUint8Array is a magic function that will encode the base64 public key
// to Array buffer which is needed by the subscription option
const urlB64ToUint8Array = base64String => {
   const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
   const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')
   const rawData = atob(base64)
   const outputArray = new Uint8Array(rawData.length)
   for (let i = 0; i < rawData.length; ++i) {
       outputArray[i] = rawData.charCodeAt(i)
   }
   return outputArray
}

const registerServiceWorker = async () => {
   const swRegistration = await navigator.serviceWorker.register($('#permission-btn').data('serviceurl'))
       .then(function(registration) {
           // Use the PushManager to get the user's subscription to the push service.
           return registration.pushManager.getSubscription()
               .then(function(subscription) {
                   // If a subscription was found, return it.
                   if (subscription) {
                       return subscription;
                   }

                   // Otherwise, subscribe the user (userVisibleOnly allows to specify that we don't plan to
                   // send notifications that don't have a visible effect for the user).
                   const applicationServerKey = urlB64ToUint8Array(
                       'BM_mFg3zewrNimyR7EF9EMl-HFkbkwDja_vU6b782nL1MbIjekYKYHa8Dy7CMoNqLQBtuzNGDy3TDh-ZqdFLNFY'
                   )
                   const options = {applicationServerKey, userVisibleOnly: true}
                   return registration.pushManager.subscribe(options);
               }).then(function(subscription) {
                   const SERVER_URL = '/actions/maintenance/trigger/subscribe'
                   const subscriptionParams = subscription.toJSON();
                   const response = fetch(SERVER_URL, {
                       method: 'post',
                       headers: {
                           'Content-Type': 'application/json',
                           'X-CSRF-TOKEN': $("input[name='CRAFT_CSRF_TOKEN']").val()
                       },
                       body: JSON.stringify({
                           'subscription': subscription.toJSON()
                       })
                   })
                   return response
               })
       })
   return swRegistration
}

const requestNotificationPermission = async () => {
   const permission = await window.Notification.requestPermission()
   // value of permission can be 'granted', 'default', 'denied'
   // granted: user has accepted the request
   // default: user has dismissed the notification permission popup by clicking on x
   // denied: user has denied the request.
   if (permission !== 'granted') {
       throw new Error('Permission not granted for Notification')
   }
}

const main = async () => {
   check()
   const permission = await requestNotificationPermission()
   const swRegistration = await registerServiceWorker()
}

$(document).ready(function(){
   $('#permission-btn').click(function(){
       main();
   });
});

In the above code, you will see that the js is triggered once our button has been clicked, this then checks the browsers permissions to see if notifications have been granted, denied, or remain to be the default value. Based on this value we either exit with an error if not granted, or we register our service worker if the user has allowed these notifications. The service worker is registered into the users browser locally using the registration.pushManager.subscribe(options); function.

{% set serviceWorkerUrl = craft.app.assetManager.publishedUrl('@adigital/maintenance/assetbundles/indexcpsection/dist', true, 'js/Service.js') %}
<a href="#" id="permission-btn" data-serviceurl="{{ serviceWorkerUrl }}" data-icon="bell" class="btn">Notify me</a>

This smaller code block shows how to create the notification button in out html/twig template. We are using Craft and have set this up within a plugin dashboard area inside the CMS control panel. But this can be also be on the front end. The key thing is that you are calling the correct url for the Service.js file you have created. You will notice in the js block that the button element I've used to initiate the code has an id of "permission-btn", it also has a data-serviceurl attribute which is set to the url of the Service.js file in our asset bundle, it is important that you set both of these properties in your html otherwise the js won't work correctly.

self.addEventListener('activate', async () => {
   console.log('service worker activate')
})

Once our service worker has been registered, we use ajax to post some information through to another page on our site where we save these details into our database. This step is necessary for us to be able to push out notifications lateron. We are also ajax loading a service.js file which for now contains a simple console log to tell us that it is working, this can be seen below in the next code snippet. We will add additional code into this service.js file later. For now we just want to check that we can reach it whilst running our tests.

We can test that the service worker has been created by going to about:serviceworkers on firefox or chrome://serviceworker-internals/ on chrome. You should see a service worker with the domain name of the page you created it on. From this page you can also remove service workers so it’s handy to always have this window open when testing.

Configuring the back end code

In our example we are using CraftCMS, but this can of course be done on other systems if required. We will need to create our VAPID (Voluntary Application Server Identification) keys which are required by some browsers for security. We do this by running npm install -g web-push on our local machine or web server, and then running web-push generate-vapid-keys to print out our keys onto the command line. Please make sure that you make a record of these because each time the command is run, a different set of keys will be created.

In our front end js we are posting to a backend controller to save the subscription to the database. It's time to set up the handling of this data. I'm using php in a Craft plugin, but this can also be done in js on a nuxt server if thats what you're into. Theres a great article on medium if you're using a js backend.

use adigital\maintenance\models\Subscription;
use adigital\maintenance\records\Subscription as SubscriptionRecord;

$params = json_decode($request->getRawBody(), true);
$model = new Subscription();
$model->userId = Craft::$app->user->id;
$model->origin = str_replace($request->getOrigin(), '', $request->getReferrer());
$model->vapidKeys = json_encode([
   'publicKey' => 'BM_mFg3zewrNimyR7EF9EMl-HFkbkwDja_vU6b782nL1MbIjekYKYHa8Dy7CMoNqLQBtuzNGDy3TDh-ZqdFLNFY',
   'privateKey' => 'pnxnyTmPWGGXNjeBTbpC1QiIvFKC9VKNWrAF0sKEDUg'
]);
$model->subscription = json_encode($params['subscription']);

// check userId and origin
$record = SubscriptionRecord::find()->where(['userId' => $model->userId, 'origin' => $model->origin, 'subscription' => $model->subscription])->one();
if ($record === null) {
   $record = new SubscriptionRecord();
}
$record->setAttributes($model->getAttributes(), false);
return $record->save();

In the code example above, you will need to use your own VAPID keys instead of my example ones. You'll also need to set up a subscription record to save the values into your database tables through your plugin.

The steps for creating plugin records can be found either on the Craft docs, or in some of our other blog posts we've written about plugin development. It's important that you save the subscriptions into your database otherwise you won’t be able to push out any notifications to your recipients, so make sure you do this and test that it is working correctly.

Sending out a notification

First of all we need to add "minishlink/web-push": "v5.2.4" into our composer.json file. This will provide us with the necessary libraries for sending out our push notifications once we are fully set up. This is a php library but there are also other libraries available for other languages. By using this library, we don’t need to set up a subscription to any 3rd party apis, we can do all of the background work onsite.

Next we will need to create a php function which uses the included library to send out the notification.

use Minishlink\WebPush\MessageSentReport;
use Minishlink\WebPush\Subscription as WebPushSubscription;
use Minishlink\WebPush\WebPush;

$subscriptionRecords = Maintenance::$plugin->subscriptions->get()->where(['userId' => $userId]);
if ($subscriptionRecords->count() > 0) {
   $auth = [
       'VAPID' => [
           'subject' => 'https://www.windermere-lakecruises.co.uk',
           'publicKey' => 'BM_mFg3zewrNimyR7EF9EMl-HFkbkwDja_vU6b782nL1MbIjekYKYHa8Dy7CMoNqLQBtuzNGDy3TDh-ZqdFLNFY',
           'privateKey' => 'pnxnyTmPWGGXNjeBTbpC1QiIvFKC9VKNWrAF0sKEDUg'
       ]
   ];
   $webPush = new WebPush($auth);
   $webPush->setReuseVAPIDHeaders(true);
   $payload = json_encode([
       'subject' => $message,
       'msg' => 'Read more about this job',
       'url' => '/' . Craft::$app->config->general->cpTrigger . '/' . $url
   ]);
   foreach ($subscriptionRecords->all() as $subscriptionRecord) {
       $subscription = WebPushSubscription::create((array)json_decode($subscriptionRecord->subscription, true));
       $webPush->sendNotification($subscription, $payload);
   }
   /** @var $report MessageSentReport */
   foreach ($webPush->flush() as $report) {
       $endpoint = (string)$report->getRequest()->getUri();
       if ($report->isSuccess()) {
           Craft::info("[v] Message sent successfully for subscription {$endpoint}.");
       } else {
           Craft::warning($report);
       }
   }
}

Again, please make sure that you include your own VAPID keys in the auth array. These keys must match the keys which were used when the user subscribed, it would be best practice to store these in your .env file but for the purposes of this example I’ve put them into the code so that you can easily see which length key goes where. If these keys don’t match your frontend keys used to save the notification, then it won’t appear on some browsers because the required security checks will fail.

The json encoded payload array can contain anything you wish, this is a completely custom array I’ve built which is passed through to our service.js file. Remember we created this earlier with only a single function to tell us when a service worker had been activated. Now we need to flesh this out with some additional code. Add the below code into your service.js file beneath the activate function we added earlier.

self.addEventListener('push', function(event) {
   const payload = event.data ? JSON.parse(event.data.text()) : {'subject': 'New notification', 'msg': 'no payload', 'url': null};
   event.waitUntil(
       self.registration.showNotification(payload.subject, {
           body: payload.msg,
           icon: '/android-chrome-192x192.png',
           vibrate: [300, 100, 400, 100, 400, 100, 400],
           data: {
               url: payload.url
           }
       })
   );
})

self.addEventListener('notificationclick', function(event) {
   event.notification.close();
   if (clients.openWindow && event.notification.data.url) {
       event.waitUntil(clients.openWindow(event.notification.data.url));
   }
})

The payload array which you’ve set in your php code can now be called in the push function to define some of the options around the notification which will be shown. You can see that I’ve used the payload to define the subject, body, and url in this particular example. You can also define the icon if needed but I’ve left this hardcoded as the default site icon.

The second function which fires on notification click is used to close the notification window immediately and then open a window using the provided url. This will open in the devices browser which was used to make the initial subscription to our notifications. So for example, if you subscribed in chrome and your default browser is firefox, the notification will still be opened in chrome.

IMG 0099

A push notification on a mobile device for an internal jobs system

How do people subscribe

As mentioned before in the Best Practices section, we don’t want to just show a prompt as soon as the page is loaded. What we'll be doing instead is including a button with a title such as subscribe, notify me, etc.

Clicking this button will initiate the notification prompt and users are much more likely to allow it as they’ve done this intentionally and are fully aware of what they are subscribing to. All they have to do is then click the Allow option within the prompt and that’s it.

As soon as the next notification is pushed out, they will receive it. There is no form submission or need for data entry on the users side. This makes it both secure and GDPR compliant. Unsubscribing is a case of clicking the icon infront of the url to display the current sites permissions and then removing the one associated with notifications. This can also be done through the browsers settings.

Further improvements and ideas

Now that we’ve set up our push notifications, we can utilise these for many different use cases. The most simple integration would be to add a notify button to a blog index page, and every time a new entry is posted we add some custom code into the element save event hook to call our push notification php function to notify all subscribers.

We can take this one step further though, I’ve used these push notifications within an internal job tracking system and set up some additional rules and requirements around this. I’m storing a userId so that users are only notified about jobs assigned to them. Notifications are sent when a job has been assigned, when a new comment is added, and when the status of the job is updated, e.g. marked on hold or complete.

I’ve also added some logic so that users who trigger the actions aren’t notified about their own updates which they are posting. There is no need to notify the person who made the update as this will just become annoying for the user.

Additionally I’m only notifying users if they have logged into the cms today. This means that we aren’t sending out notifications to their mobile phones on their days off. The user still get emails about the jobs which they can pick up when they are next in, but the actual push notifications only occur on days where they’ve logged into the system at least once.

Conclusion

Originally I thought push notifications were a gimmick and a bit spammy partly because I’ve seen them creeping into more websites recently and I always dismiss the prompt immediately. In reality though I’ve now realised that this is just down to being a poor implementation of a potentially powerful tool. By defining some best practices e.g. allowing the user to trigger the prompt themselves, being fully informed of what they are wanting to be notified about. Push notifications have the potential to provide a lot of value when following these rules.

Imagine for example a product is out of stock, and when new stock arrives all of your customers who are interested are notified. When the customer knows that they will be notified on arrival of new stock, they are more likely to wait instead of shopping around somewhere else. When the notification goes out it also brings these users back onto the site so you can upsell to them. The principles behind this technique are very similar to the way that abandoned cart emails work, only using push notifications instead.

By providing users with a sensible implementation of push notifications following the best practices outlined above, there is no reason why they can’t become a great tool for your business to help drive engagement and keep traffic returning to your site. Tailored notifications based on a number of parameters will give users a more personal experience when filtered down and segmented properly. We see email newsletters go out where they aren’t opened for a good 5 years sometimes, if people don’t check their emails regularly or haven’t linked them up with their phones then this can be a great way to reach these individuals.

Something to be aware of is that the example code I've shown in this article won’t work currently on iOS, you will need to include an additional composer library zendframework/zendservice-apple-apns from https://github.com/zendframework/ZendService_Apple_Apns and follow the documentation over at https://framework.zend.com/manual/2.4/en/modules/zendservice.apple.apns.html to get this working.