The Push Notification Orchestrator and iOS Heartbeats in Laravel (Part 4 of 4)
Dinesh Wijethunga
The Problem With Calling FCM and APNs Directly
Without an orchestrator, every place that needs to send a push notification has to look up the user's tokens, check each token's platform, call the right sender, and handle errors. That's duplicated in every controller, listener, and job.
The solution: one class with named methods per event type. Everything else in the app calls that β no platform knowledge required outside this one file.
Binding the Services
In Modules/PushNotification/app/Providers/PushNotificationServiceProvider.php:
<?php
namespace Modules\PushNotification\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\PushNotification\Services\ApnsService;
use Modules\PushNotification\Services\FcmService;
use Modules\PushNotification\Services\PushNotificationService;
class PushNotificationServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(FcmService::class);
$this->app->singleton(ApnsService::class);
$this->app->singleton(PushNotificationService::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('PushNotification', 'database/migrations'));
$this->mergeConfigFrom(module_path('PushNotification', 'config/config.php'), 'push-notification');
}
}
singleton() means Laravel creates each service once and reuses it β important because FcmService caches access tokens between calls.
The PushNotificationService Orchestrator
Place this at Modules/PushNotification/app/Services/PushNotificationService.php.
<?php
namespace Modules\PushNotification\Services;
use Illuminate\Support\Facades\Log;
use Modules\PushNotification\Models\DeviceToken;
use Modules\PushNotification\Models\LiveActivityToken;
class PushNotificationService
{
public function __construct(
private readonly FcmService $fcm,
private readonly ApnsService $apns,
) {}
// ββ Ride offer alert β Driver βββββββββββββββββββββββββββββββββ
public function notifyDriverOfOffer(
int $driverId,
string $bookingId,
string $pickupAddress,
float $fare,
string $currency = 'AED',
): void {
$title = 'New Ride Request';
$body = "Pickup: {$pickupAddress} β {$currency} {$fare}";
$this->broadcastToUser(
userId: $driverId,
appType: 'driver',
fcmNotification: ['title' => $title, 'body' => $body],
fcmData: ['type' => 'ride_offer', 'ride_id' => $bookingId],
apnsPayload: [
'aps' => [
'alert' => ['title' => $title, 'body' => $body],
'sound' => 'default',
'content-available' => 1,
'mutable-content' => 1,
],
'type' => 'ride_offer',
'ride_id' => $bookingId,
],
apnsTopic: config('push-notification.apns.driver_bundle_id'),
apnsPushType: 'alert',
apnsPriority: 10,
apnsExpiration: time() + 30, // stale offer is worse than no notification
);
}
// ββ Ride status silent wake-up β Customer βββββββββββββββββββββ
/**
* @param string $status driver_on_way|driver_arrived|trip_started|completed|cancelled
*/
public function updateCustomerRideStatus(
int $customerId,
string $bookingId,
string $status,
): void {
$this->broadcastToUser(
userId: $customerId,
appType: 'customer',
fcmNotification: null, // data-only (silent)
fcmData: ['type' => 'ride_update', 'ride_id' => $bookingId, 'status' => $status],
apnsPayload: [
'aps' => ['content-available' => 1],
'type' => 'ride_update',
'ride_id' => $bookingId,
'status' => $status,
],
apnsTopic: config('push-notification.apns.customer_bundle_id'),
apnsPushType: 'background',
apnsPriority: 5, // silent pushes MUST use priority 5 on iOS
);
}
// ββ Chat message β recipient (any role) βββββββββββββββββββββββ
public function notifyChatRecipient(
int $recipientId,
int $senderId,
string $senderName,
string $messageText,
): void {
$tokens = DeviceToken::forUser($recipientId)->get();
if ($tokens->isEmpty()) {
Log::debug("PushNotificationService: no tokens for chat recipient [{$recipientId}].");
return;
}
foreach ($tokens as $deviceToken) {
$apnsTopic = $deviceToken->app_type === 'driver'
? config('push-notification.apns.driver_bundle_id')
: config('push-notification.apns.customer_bundle_id');
try {
if ($deviceToken->platform === 'android') {
$this->fcm->deliver(
token: $deviceToken->token,
appType: $deviceToken->app_type,
notification: ['title' => $senderName, 'body' => $messageText],
data: [
'event' => 'message_received',
'sender_id' => (string) $senderId,
'sender_name' => $senderName,
'message' => $messageText,
],
);
} else {
$this->apns->deliver(
token: $deviceToken->token,
payload: [
'aps' => [
'alert' => ['title' => $senderName, 'body' => $messageText],
'sound' => 'default',
'content-available' => 1,
'mutable-content' => 1,
],
'event' => 'message_received',
'sender_id' => (string) $senderId,
'sender_name' => $senderName,
'message' => $messageText,
],
apnsTopic: $apnsTopic,
pushType: 'alert',
priority: 10,
);
}
} catch (\Throwable $e) {
Log::error("PushNotificationService: chat push failed for user [{$recipientId}]: {$e->getMessage()}");
}
}
}
// ββ Driver heartbeat (iOS only) βββββββββββββββββββββββββββββββ
public function pingDriver(int $driverId): void
{
$tokens = DeviceToken::forUser($driverId)->driver()->ios()->get();
foreach ($tokens as $deviceToken) {
$this->apns->deliver(
token: $deviceToken->token,
payload: ['aps' => ['content-available' => 1], 'type' => 'driver_heartbeat'],
apnsTopic: config('push-notification.apns.driver_bundle_id'),
pushType: 'background',
priority: 5,
);
}
}
// ββ Customer heartbeat (iOS only) βββββββββββββββββββββββββββββ
public function pingCustomer(int $customerId, string $bookingId): void
{
$tokens = DeviceToken::forUser($customerId)->customer()->ios()->get();
foreach ($tokens as $deviceToken) {
$this->apns->deliver(
token: $deviceToken->token,
payload: [
'aps' => ['content-available' => 1],
'type' => 'customer_heartbeat',
'ride_id' => $bookingId,
],
apnsTopic: config('push-notification.apns.customer_bundle_id'),
pushType: 'background',
priority: 5,
);
}
}
// ββ Live Activity updates (iOS only) ββββββββββββββββββββββββββ
/**
* @param bool $end Pass true to dismiss the widget on completion or cancellation
*/
public function refreshRideWidget(
int $customerId,
string $bookingId,
string $status,
array $extra = [],
bool $end = false,
): void {
$tokens = LiveActivityToken::forUser($customerId)
->where('app_type', 'customer')
->where('activity_type', 'ride_tracking')
->where('activity_id', $bookingId)
->active()
->get();
if ($tokens->isEmpty()) {
return;
}
$widgetBundle = config('push-notification.apns.customer_widget_bundle_id');
$contentState = array_merge(['status' => $status], $extra);
$event = $end ? 'end' : 'update';
foreach ($tokens as $lat) {
try {
$this->apns->deliverLiveActivity(
token: $lat->token,
widgetBundle: $widgetBundle,
event: $event,
contentState: $contentState,
priority: 10,
);
if ($end) {
LiveActivityToken::removeToken($lat->token);
}
} catch (\Throwable $e) {
Log::error("PushNotificationService: Live Activity update failed for customer [{$customerId}]: {$e->getMessage()}");
}
}
}
// ββ Internal broadcast ββββββββββββββββββββββββββββββββββββββββ
private function broadcastToUser(
int $userId,
string $appType,
?array $fcmNotification,
array $fcmData,
array $apnsPayload,
string $apnsTopic,
string $apnsPushType,
int $apnsPriority = 10,
int $apnsExpiration = 0,
): void {
$tokens = DeviceToken::forUser($userId)
->where('app_type', $appType)
->get();
if ($tokens->isEmpty()) {
Log::debug("PushNotificationService: no tokens for user [{$userId}] app [{$appType}].");
return;
}
foreach ($tokens as $deviceToken) {
try {
if ($deviceToken->platform === 'android') {
$this->fcm->deliver(
token: $deviceToken->token,
appType: $appType,
notification: $fcmNotification,
data: $fcmData,
);
} else {
$this->apns->deliver(
token: $deviceToken->token,
payload: $apnsPayload,
apnsTopic: $apnsTopic,
pushType: $apnsPushType,
priority: $apnsPriority,
expiration: $apnsExpiration,
);
}
} catch (\Throwable $e) {
// Never let a push failure break the calling flow
Log::error("PushNotificationService: failed for user [{$userId}] token [{$deviceToken->id}]: {$e->getMessage()}");
}
}
}
}
Using It From Another Module
Any listener or job just injects PushNotificationService:
<?php
namespace Modules\Booking\Listeners;
use Modules\Booking\Events\DriverAssigned;
use Modules\PushNotification\Services\PushNotificationService;
class NotifyDriverOfNewRide
{
public function __construct(
private readonly PushNotificationService $push,
) {}
public function handle(DriverAssigned $event): void
{
$this->push->notifyDriverOfOffer(
driverId: $event->driverId,
bookingId: $event->bookingId,
pickupAddress: $event->pickupAddress,
fare: $event->fare,
);
}
}
The iOS Heartbeat Problem
iOS aggressively suspends background apps to save battery. After about 30 seconds in the background, the process freezes β it can't receive silent pushes or update location until it's woken again.
For a ride app, this breaks everything: the driver app misses incoming offers, the customer app misses status updates.
The fix: a scheduled silent push every 1β2 minutes. iOS wakes the app for ~30 seconds, it refreshes its state, then sleeps. The next heartbeat wakes it again. Android doesn't need this because foreground services run indefinitely.
Driver Heartbeat Command
Place at Modules/PushNotification/app/Console/Commands/SendDriverHeartbeatCommand.php:
<?php
namespace Modules\PushNotification\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
use Modules\PushNotification\Models\DeviceToken;
use Modules\PushNotification\Services\PushNotificationService;
class SendDriverHeartbeatCommand extends Command
{
protected $signature = 'push:driver-heartbeat';
protected $description = 'Send APNs silent heartbeat to online iOS drivers';
public function handle(PushNotificationService $push): void
{
$driverIds = DeviceToken::driver()->ios()->pluck('user_id')->unique();
if ($driverIds->isEmpty()) {
return;
}
$redis = Redis::connection();
$count = 0;
foreach ($driverIds as $driverId) {
// Only ping drivers whose online key exists in Redis.
// Your real-time service sets this key on every location update (TTL 180s).
if ($redis->exists("driver:{$driverId}:online")) {
$push->pingDriver($driverId);
$count++;
}
}
$this->line("Driver heartbeat sent to {$count} online iOS drivers.");
}
}
Customer Heartbeat Command
Place at Modules/PushNotification/app/Console/Commands/SendCustomerHeartbeatCommand.php:
<?php
namespace Modules\PushNotification\Console\Commands;
use Illuminate\Console\Command;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\PushNotification\Services\PushNotificationService;
class SendCustomerHeartbeatCommand extends Command
{
protected $signature = 'push:customer-heartbeat';
protected $description = 'Send APNs silent heartbeat to customers with an active ride';
public function handle(PushNotificationService $push): void
{
$activeBookings = Booking::inStatus(
BookingStatus::Searching,
BookingStatus::DriverFound,
BookingStatus::Accepted,
BookingStatus::Arrived,
BookingStatus::InProgress,
)->get(['id', 'rider_id']);
foreach ($activeBookings as $booking) {
$push->pingCustomer(
customerId: $booking->rider_id,
bookingId: $booking->id,
);
}
$this->line("Customer heartbeat sent for {$activeBookings->count()} active ride(s).");
}
}
Scheduling
In routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('push:driver-heartbeat')->everyTwoMinutes();
Schedule::command('push:customer-heartbeat')->everyMinute();
Cron entry: * * * * * php /var/www/artisan schedule:run >> /dev/null 2>&1
The Complete Method Name Map
| Class | Method in these blog posts | What it does |
|---|---|---|
DeviceToken | saveToken() | Upsert token on every app launch |
DeviceToken | removeToken() | Delete a dead token immediately |
LiveActivityToken | saveActivityToken() | Upsert Live Activity token |
LiveActivityToken | removeToken() | Delete a dead Live Activity token |
LiveActivityToken | closeActivity() | Delete all tokens for a finished activity |
FcmService | deliver() | Send to one Android device |
FcmService | buildJwt() | Build RS256 JWT for OAuth2 |
FcmService | getAccessToken() | Fetch and cache OAuth2 token |
FcmService | loadCredentials() | Read JSON credentials file |
ApnsService | deliver() | Send to one iOS device |
ApnsService | deliverLiveActivity() | Update a lock screen widget |
ApnsService | buildJwt() | Build ES256 JWT from .p8 key |
ApnsService | convertSignature() | Convert DER signature to P-1363 |
PushNotificationService | notifyDriverOfOffer() | Ride offer alert β driver |
PushNotificationService | updateCustomerRideStatus() | Silent status update β customer |
PushNotificationService | notifyChatRecipient() | Chat message β any user |
PushNotificationService | pingDriver() | iOS heartbeat β driver |
PushNotificationService | pingCustomer() | iOS heartbeat β customer |
PushNotificationService | refreshRideWidget() | Live Activity update β customer |
PushNotificationService | broadcastToUser() | Internal fan-out by platform |
Want this entire stack already implemented in a production-ready Laravel modular monolith boilerplate? Skip months of setup β get auth, push notifications, multi-tenancy, and role-based access out of the box. Check out the boilerplate β
Receiving payments for your boilerplate sales from MENA clients? Wise has zero hidden conversion fees β the best option for UAE and GCC-based developers.
Reviews & Ratings
Sign in to leave a review.
