- November 17, 2021
Using Headless Craft CMS to Send Mobile Push Notifications
Happy Cog recently launched a new website for Optimize, an online community that teaches members how to improve their lives, drawing wisdom from a variety of teachers and sources. The foundation of the project is a headless Craft back-end that powers a Nuxt-based website and both Android and iOS mobile apps, all via a custom-built API layer. One of the requirements for the mobile apps was the ability to send push notifications for Optimize’s “Daily Wisdom” posts, which we accomplished using a custom Craft module.
Because there are so many different ways push notifications can be handled – how they are triggered, when they are sent – I didn’t try to package this up into a reusable plugin. Instead, I am including some sample code for various key parts of the process, which can be incorporated into a custom module built to the particular specs of a given project.
Registering Devices
Before a notification can be pushed out, each mobile device must register itself to receive notifications from the app. Users must also be able to unregister themselves if they decide they prefer not to receive pushes at some point in the future.
Creating the Custom Database Table
We started by creating a custom table in our database to hold all the registered devices. Remember that each user may have multiple tokens, if they use the app on several phones or tablets. We also want to be able to identify what device type (Apple or Android) a token belongs to and whether it is a live or test-mode token. We end up with the following columns in the table (and associated record attributes), in addition to the various ID and date columns that Craft records include by default:
userId
token
deviceType
development
(boolean)
By creating this as a Record, we will get basic CRUD functionality for free, allowing us to do things like this right off the bat: \Utility\PushNotificationsModule\records\DevicePushToken::find()->userId($currentUser->id)->all();
<?php /** * PushNotifications module for Craft CMS 3.x * * @link https://www.happycog.com * @copyright Copyright (c) 2021 Happy Cog */ namespace Utility\PushNotificationsModule\records; use craft\db\ActiveRecord; use craft\elements\User; use DateTime; use yii\db\ActiveQueryInterface; /** * @property int $id ID * @property int $userId User ID * @property string $token The token itself * @property string $deviceType The device type: Apple or Android * @property bool $development Is this a development-mode token? * @property DateTime $dateCreated Date created * @property DateTime $dateUpdated Date updated * @property string $uid */ class DevicePushToken extends ActiveRecord { /** @var string */ public const DEVICE_TYPE_APPLE = 'apple'; /** @var string */ public const DEVICE_TYPE_ANDROID = 'android'; /** * @inheritdoc */ public function rules() { return [ [['token', 'userId'], 'required'], ['token', 'unique'], ['token', 'string', 'max' => 255], ['deviceType', 'in', 'range' => [self::DEVICE_TYPE_APPLE, self::DEVICE_TYPE_ANDROID]], ['deviceType', 'default', 'value' => self::DEVICE_TYPE_APPLE], ]; } /** * @inheritdoc */ public static function tableName() { return '{{%pushnotificationsmodule_devicepushtoken}}'; } /** * Returns the associated user * * @return ActiveQueryInterface The relational query object. */ public function getUser(): ActiveQueryInterface { return $this->hasOne(User::class, ['id' => 'userId']); } }
Adding API Endpoints
Let’s register some devices! The device-side mechanics of asking for user permission and generating a token are outside the scope of this article, but once you have that token you will need an API to submit it to. We created a very simple controller in our module, that adds two new actions: actions/push-notifications-module/add-token
and actions/push-notifications-module/remove-token
. All we are doing there is creating and deleting those DevicePushToken records.
<?php namespace Utility\PushNotificationsModule\controllers; use Craft; use craft\helpers\StringHelper; use craft\web\Controller; use Utility\PushNotificationsModule\records\DevicePushToken; use yii\web\BadRequestHttpException; use yii\web\Response; class PushNotificationsController extends Controller { public $enableCsrfValidation = false; /** * @throws BadRequestHttpException */ public function actionAddToken(): Response { $this->requireLogin(); $currentUser = Craft::$app->getUser()->getIdentity(); $token = $this->request->getRequiredBodyParam('token'); $deviceType = $this->request->getBodyParam('deviceType', 'apple'); $development = StringHelper::toBoolean($this->request->getBodyParam('development', '')); if (DevicePushToken::findOne(['token' => $token])) { return $this->asErrorJson('Token already exists.'); } $tokenRecord = new DevicePushToken([ 'userId' => $currentUser->id, 'deviceType' => $deviceType, 'token' => $token, 'development' => $development, ]); if (!$tokenRecord->save()) { return $this->asJson(['errors' => $tokenRecord->getErrors()]); } return $this->asJson(['success' => true]); } /** * @throws BadRequestHttpException */ public function actionRemoveToken(): Response { $this->requireLogin(); $currentUser = Craft::$app->getUser()->getIdentity(); $token = Craft::$app->getRequest()->getRequiredBodyParam('token'); $tokenRecord = DevicePushToken::findOne([ 'token' => $token, 'userId' => $currentUser->id, ]); if (!$tokenRecord) { return $this->asErrorJson('Token does not exist or does not belong to the current user.'); } try { $tokenRecord->delete(); } catch (\Throwable $e) { return $this->asErrorJson($e->getMessage()); } return $this->asJson(['success' => true]); } }
Sending Notifications
Now that we’ve got all the housekeeping out of the way, we are ready to start sending notifications. The first decision to make is how those will be triggered. You could send them manually using a button connected to a custom controller, on a set schedule, or any number of other options. In our case, however, we decided to send a notification whenever a new entry is published in one particular section. Seems simple enough, right? Except that Craft does not have an event that fires when an entry is published, only when it is saved. That means you are unable to use that for pre-scheduled posts. The best workaround for that turned out to be setting up a cron job that runs every few minutes, checks for any un-pushed entries, and sends out the notification. Once the notification for an entry is created, we update a custom field to mark it as “sent” so we don’t get duplicate notifications.
<?php /** * Push Notifications module for Craft CMS 3.x * * @link https://www.happycog.com * @copyright Copyright (c) 2021 Happy Cog */ namespace Utility\PushNotificationsModule\console\controllers; use Craft; use craft\console\Controller; use craft\elements\Entry; use craft\helpers\Console; use Utility\PushNotificationsModule\jobs\SendNotifications; class SendController extends Controller { private string $pushNotificationSection = 'dailyWisdom'; private int $numberToSend = 1; /** * @return void */ public function actionIndex(): void { $notificationsToSend = Entry::find()->section($this->pushNotificationSection)->limit($this->numberToSend)->one(); if (empty($notificationsToSend)) { Console::output(Craft::t('app', 'No notifications to send.')); return; } Console::output(Craft::t( 'app', 'Queuing {total, number} {total, plural, =1{notification} other{notifications}} to push.', ['total' => count($notificationsToSend)], )); foreach ($notificationsToSend as $entry) { $job = new SendNotifications([ 'description' => Craft::t('app', 'Sending push notification: {title}', ['title' => $entry->title]), 'entryId' => $entry->id, ]); $job->execute(null); } } }
Pushing to Apple Devices
The API to send notifications to iOS devices is known as Apple Push Notification Service, or APNS. It is also possible to send notifications through Firebase (as shown below) but that requires you to modify the mobile app to use Firebase’s SDK. We will stick to the native Apple APIs for now.
To begin, go to Apple’s Developer Center and generate a new Auth Key. You will need the key itself, along with the associated Team ID, Bundle ID, & Key ID. We will store those values as environmental variables and use them to generate the authorization header that APNS requires in order to make API requests.
Now we simply loop through all of the tokens and send a request for each one. In our implementation, we do this in batches, each an individual queue job, to prevent timeouts once we have a significant number of devices registered.
/** * @param NotificationPayload $payloadData * @param array $tokens * @return int * @throws GuzzleException */ public function sendAppleNotifications(NotificationPayload $payloadData, array $tokens): int { $bundleId = App::env('APPLE_CERTIFICATE_BUNDLE_ID'); $client = Craft::createGuzzleClient([ 'base_uri' => 'https://api.push.apple.com/3/device/', 'headers' => [ 'apns-topic' => $bundleId, 'Authorization' => 'Bearer ' . $this->generateAppleAuthorizationToken(), ], 'curl' => [ CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2_0, ], 'body' => $payloadData->forApple(), ]); $sentCounter = 0; foreach ($tokens as $token) { try { $response = $client->post($token); } catch (RequestException $e) { $responseData = json_decode($e->getResponse()->getBody()); $this->log("Can't connect to Apple Push Service: {$responseData->reason}", $token); } finally { if (isset($response) && $response->getStatusCode() !== 200) { $this->log("Error while sending Apple Push Notification: {$response->getBody()}", $token); } else { $sentCounter++; } } } return $sentCounter; } /** * @return string */ private function generateAppleAuthorizationToken(): string { $keyId = App::env('APPLE_CERTIFICATE_KEY_ID'); $teamId = App::env('APPLE_CERTIFICATE_TEAM_ID'); $key = str_replace('\n', "\n", App::env('APPLE_CERTIFICATE_KEY')); $header = base64_encode(json_encode(['alg' => 'ES256', 'kid' => $keyId])); $claims = base64_encode(json_encode(['iss' => $teamId, 'iat' => time()])); $pkey = openssl_pkey_get_private($key); openssl_sign("$header.$claims", $signature, $pkey, 'sha256'); $signed = base64_encode($signature); return "$header.$claims.$signed"; }
Pushing to Android Devices Using Firebase
Google uses an API provided by their Firebase service, called Firebase Cloud Messaging, to send push notifications to Android devices. Unlike with APNS, we are able to send a notification to a list of device tokens all at once. This simplifies our code significantly.
/** * @param NotificationPayload $payloadData * @param array $tokens * @return int * @throws GuzzleException */ public function sendAndroidNotifications(NotificationPayload $payloadData, array $tokens): int { $apiKey = App::env('ANDROID_API_KEY'); $client = Craft::createGuzzleClient([ 'base_uri' => 'https://fcm.googleapis.com/fcm/send', 'headers' => [ 'Authorization' => 'key=' . $apiKey, 'Content-Type' => 'application/json', ], ]); try { $data = [ 'registration_ids' => $tokens, 'priority' => 'high', 'data' => $payloadData->forAndroid(), ]; $response = $client->post('https://fcm.googleapis.com/fcm/send', [ 'body' => json_encode($data, JSON_THROW_ON_ERROR), ]); $responseData = json_decode($response->getBody()); $sentCounter = $responseData->success; } catch (RequestException $e) { $this->log("Can't connect to Firebase: {$e->getResponse()->getBody()}"); } catch (JsonException $e) { $this->log("Invalid push notification data: {$e->getMessage()}"); } finally { if (isset($response) && $response->getStatusCode() !== 200) { $this->log("Error while sending Android Push Notification: {$response->getBody()}"); } } return $sentCounter ?? 0; }
Wrap-up
When used responsibly, push notifications can be a great way to engage users with contextual information they may not have found on their own. Think of the sample code above as raw materials you can use to build your own notifications framework. The possibilities are endless and every project’s needs are unique. We would love to see what you build!