Article

The Push Notification Orchestrator and iOS Heartbeats in Laravel (Part 4 of 4)

D

Dinesh Wijethunga

June 13, 2026 8 min readIntermediate 35 views
πŸ“

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

ClassMethod in these blog postsWhat it does
DeviceTokensaveToken()Upsert token on every app launch
DeviceTokenremoveToken()Delete a dead token immediately
LiveActivityTokensaveActivityToken()Upsert Live Activity token
LiveActivityTokenremoveToken()Delete a dead Live Activity token
LiveActivityTokencloseActivity()Delete all tokens for a finished activity
FcmServicedeliver()Send to one Android device
FcmServicebuildJwt()Build RS256 JWT for OAuth2
FcmServicegetAccessToken()Fetch and cache OAuth2 token
FcmServiceloadCredentials()Read JSON credentials file
ApnsServicedeliver()Send to one iOS device
ApnsServicedeliverLiveActivity()Update a lock screen widget
ApnsServicebuildJwt()Build ES256 JWT from .p8 key
ApnsServiceconvertSignature()Convert DER signature to P-1363
PushNotificationServicenotifyDriverOfOffer()Ride offer alert β†’ driver
PushNotificationServiceupdateCustomerRideStatus()Silent status update β†’ customer
PushNotificationServicenotifyChatRecipient()Chat message β†’ any user
PushNotificationServicepingDriver()iOS heartbeat β†’ driver
PushNotificationServicepingCustomer()iOS heartbeat β†’ customer
PushNotificationServicerefreshRideWidget()Live Activity update β†’ customer
PushNotificationServicebroadcastToUser()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.

D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

The Push Notification Orchestrator and iOS Heartbeats in Laravel (Part 4 of 4) | DineshStack