Article

Sending iOS Push Notifications with APNs HTTP/2 in Laravel (Part 3 of 4)

D

Dinesh Wijethunga

June 10, 2026 7 min readIntermediate 19 views
πŸ“

Why iOS Push Is Different

Android's FCM accepts regular HTTPS. Apple's APNs requires HTTP/2 β€” the binary protocol. If your HTTP client doesn't support HTTP/2, APNs silently drops the connection.

APNs also uses a different JWT algorithm: ES256 (Elliptic Curve with SHA-256) instead of FCM's RS256. The private key comes from a .p8 file downloaded from Apple Developer β€” not a JSON service account.

And there's a quirk that catches everyone: PHP's openssl_sign() produces a DER-encoded signature, but APNs requires IEEE P-1363 format. Same data, different structure. We'll handle the conversion below.

What You Need from Apple Developer

  1. developer.apple.com β†’ Certificates, Identifiers & Profiles β†’ Keys.
  2. Create a new key, check Apple Push Notifications service (APNs).
  3. Download the .p8 file (only available once β€” save it).
  4. Note your Key ID (10 characters, shown in the key list) and Team ID (top-right of the portal).

Replace these placeholders in the config:

  • YOUR_KEY_ID β€” the 10-character key ID (e.g. ABC1234567)
  • YOUR_TEAM_ID β€” the 10-character team ID (e.g. XYZ9876543)
  • com.yourcompany.customerapp β€” your customer app bundle ID
  • com.yourcompany.driverapp β€” your driver app bundle ID
  • Widget bundle IDs: com.yourcompany.yourapp.YourWidget

Config File (add to Part 2's config)

'apns' => [
    'key_id'           => env('APNS_KEY_ID', 'YOUR_KEY_ID'),
    'team_id'          => env('APNS_TEAM_ID', 'YOUR_TEAM_ID'),
    'private_key_path' => env('APNS_PRIVATE_KEY_PATH', storage_path('apns/AuthKey_YOUR_KEY_ID.p8')),
    'sandbox'          => env('APNS_SANDBOX', true),   // set false in production

    'customer_bundle_id' => env('APNS_CUSTOMER_BUNDLE_ID', 'com.yourcompany.customerapp'),
    'driver_bundle_id'   => env('APNS_DRIVER_BUNDLE_ID',   'com.yourcompany.driverapp'),

    // Live Activity widget bundle IDs
    'customer_widget_bundle_id' => env('APNS_CUSTOMER_WIDGET_BUNDLE_ID', 'com.yourcompany.customerapp.RideWidget'),
    'driver_widget_bundle_id'   => env('APNS_DRIVER_WIDGET_BUNDLE_ID',   'com.yourcompany.driverapp.DriverWidget'),
],

.env:

APNS_KEY_ID=ABC1234567
APNS_TEAM_ID=XYZ9876543
APNS_PRIVATE_KEY_PATH=/var/www/storage/apns/AuthKey_ABC1234567.p8
APNS_SANDBOX=false
APNS_CUSTOMER_BUNDLE_ID=com.yourcompany.customerapp
APNS_DRIVER_BUNDLE_ID=com.yourcompany.driverapp

The ApnsService Class

Place this at Modules/PushNotification/app/Services/ApnsService.php.

<?php

namespace Modules\PushNotification\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\PushNotification\Models\DeviceToken;
use Symfony\Component\HttpClient\HttpClient;

class ApnsService
{
    private const PRODUCTION_HOST = 'https://api.push.apple.com';
    private const SANDBOX_HOST    = 'https://api.sandbox.push.apple.com';

    /**
     * Deliver a push notification to a single APNs device token.
     *
     * @param  string  $token       APNs device token
     * @param  array   $payload     Full APS payload β€” must include an 'aps' key
     * @param  string  $apnsTopic   Bundle ID of the target app
     * @param  string  $pushType    'alert' | 'background' | 'liveactivity'
     * @param  int     $priority    10 = high (alerts), 5 = normal (silent pushes)
     * @param  int     $expiration  Unix timestamp after which delivery is abandoned. 0 = no expiry.
     */
    public function deliver(
        string $token,
        array  $payload,
        string $apnsTopic,
        string $pushType   = 'alert',
        int    $priority   = 10,
        int    $expiration = 0,
    ): void {
        $host = config('push-notification.apns.sandbox')
            ? self::SANDBOX_HOST
            : self::PRODUCTION_HOST;

        $url    = "{$host}/3/device/{$token}";
        $client = HttpClient::create(['http_version' => '2.0']);

        try {
            $jwt = $this->buildJwt();

            $headers = [
                'Authorization'  => 'bearer ' . $jwt,
                'apns-topic'     => $apnsTopic,
                'apns-push-type' => $pushType,
                'apns-priority'  => (string) $priority,
            ];

            if ($expiration > 0) {
                $headers['apns-expiration'] = (string) $expiration;
            }

            $response = $client->request('POST', $url, [
                'headers' => $headers,
                'json'    => $payload,
            ]);

            $status = $response->getStatusCode();

            if ($status === 200) {
                return;
            }

            $body   = $response->toArray(throw: false);
            $reason = $body['reason'] ?? 'unknown';

            // Permanently invalid or mismatched token β€” delete from DB
            if (in_array($reason, ['BadDeviceToken', 'Unregistered', 'ExpiredToken', 'DeviceTokenNotForTopic'], true)) {
                DeviceToken::removeToken($token);
                Log::info("ApnsService: removed invalid token [{$reason}]");
                return;
            }

            if ($reason === 'TooManyRequests') {
                Log::warning('ApnsService: rate limited β€” back off sending to this device.');
                return;
            }

            Log::warning("ApnsService: delivery failed [{$status}] {$reason}");

        } catch (\Throwable $e) {
            Log::error("ApnsService: exception β€” {$e->getMessage()}");
        }
    }

    /**
     * Deliver an iOS Live Activity update or end event.
     *
     * @param  string  $token        Live Activity push token (NOT the device token)
     * @param  string  $widgetBundle e.g. com.yourcompany.app.YourWidget
     * @param  string  $event        'update' | 'end'
     * @param  array   $contentState State object the widget will render
     * @param  int     $priority     10 = time-sensitive, 5 = regular
     * @param  int     $timestamp    Unix timestamp (widget ignores stale updates)
     */
    public function deliverLiveActivity(
        string $token,
        string $widgetBundle,
        string $event,
        array  $contentState,
        int    $priority  = 10,
        int    $timestamp = 0,
    ): void {
        $payload = [
            'aps' => [
                'timestamp'     => $timestamp ?: time(),
                'event'         => $event,
                'content-state' => $contentState,
            ],
        ];

        $this->deliver(
            token:     $token,
            payload:   $payload,
            // Live Activity topic = widget bundle ID + '.push-type.liveactivity'
            apnsTopic: $widgetBundle . '.push-type.liveactivity',
            pushType:  'liveactivity',
            priority:  $priority,
        );
    }

    // ── JWT builder ───────────────────────────────────────────────

    private function buildJwt(): string
    {
        // Cache for 55 minutes β€” APNs tokens expire after 60
        return Cache::remember('apns_jwt', now()->addMinutes(55), function () {
            $keyId  = config('push-notification.apns.key_id');
            $teamId = config('push-notification.apns.team_id');

            $header  = $this->encodeBase64Url(json_encode(['alg' => 'ES256', 'kid' => $keyId]));
            $payload = $this->encodeBase64Url(json_encode(['iss' => $teamId, 'iat' => time()]));

            $signingInput = $header . '.' . $payload;
            $keyPath      = config('push-notification.apns.private_key_path');

            if (empty($keyPath) || !file_exists($keyPath)) {
                throw new \RuntimeException(
                    "APNs private key not found at [{$keyPath}]. Set APNS_PRIVATE_KEY_PATH in .env."
                );
            }

            $privateKey = openssl_pkey_get_private('file://' . $keyPath);

            if ($privateKey === false) {
                throw new \RuntimeException(
                    "APNs key at [{$keyPath}] could not be parsed. Must be a valid PEM EC key (.p8 from Apple)."
                );
            }

            openssl_sign($signingInput, $derSignature, $privateKey, OPENSSL_ALGO_SHA256);

            // APNs requires IEEE P-1363 format β€” PHP gives us DER, so convert it
            $p1363 = $this->convertSignature($derSignature);

            return $signingInput . '.' . $this->encodeBase64Url($p1363);
        });
    }

    /**
     * Convert a DER-encoded ECDSA signature to IEEE P-1363 format.
     *
     * DER layout:  0x30 {len} 0x02 {r_len} {r} 0x02 {s_len} {s}
     * P-1363:      r padded to 32 bytes || s padded to 32 bytes
     *
     * DER may prefix r or s with 0x00 when the high bit is set (ASN.1 positive-integer
     * convention). P-1363 doesn't need that β€” we strip the leading zero, then pad
     * both values to exactly 32 bytes using str_pad.
     */
    private function convertSignature(string $der): string
    {
        $offset = 0;

        // Skip SEQUENCE tag (0x30) and total length byte
        $offset += 2;

        // Read r
        $offset++;                               // skip 0x02 INTEGER tag
        $rLen = ord($der[$offset++]);
        if (ord($der[$offset]) === 0x00) {       // strip leading zero (positive-sign padding)
            $offset++;
            $rLen--;
        }
        $r = substr($der, $offset, $rLen);
        $offset += $rLen;

        // Read s
        $offset++;                               // skip 0x02 INTEGER tag
        $sLen = ord($der[$offset++]);
        if (ord($der[$offset]) === 0x00) {
            $offset++;
            $sLen--;
        }
        $s = substr($der, $offset, $sLen);

        // Pad both to exactly 32 bytes (ES256 uses a 256-bit curve)
        return str_pad($r, 32, "\x00", STR_PAD_LEFT)
             . str_pad($s, 32, "\x00", STR_PAD_LEFT);
    }

    private function encodeBase64Url(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
}

Understanding the DER β†’ P-1363 Conversion

An ECDSA signature is two big numbers: r and s. For ES256 they're each 32 bytes.

DER (what PHP produces) wraps them in ASN.1 metadata:

0x30 {total_len} 0x02 {r_len} {r_bytes} 0x02 {s_len} {s_bytes}

P-1363 (what APNs expects) is just the raw numbers concatenated:

{r β€” exactly 32 bytes} {s β€” exactly 32 bytes}

The 0x00 issue: DER adds a leading zero byte whenever the high bit of r or s is set, to signal "this is a positive integer" in ASN.1. P-1363 doesn't need that signal β€” convertSignature() strips it, then left-pads both values to exactly 32 bytes.

The Three Push Types

apns-push-typeapns-priorityWhat it does
alert10Shows a visible notification (title + body)
background5Silently wakes the app β€” user sees nothing. Must use priority 5 or iOS drops it.
liveactivity10 or 5Updates a lock screen widget

What's Next

Part 4 wires everything together: the orchestrator that routes each notification to FCM or APNs automatically, named methods per event type, and the scheduled heartbeat commands that keep iOS background processes alive during active rides.

Pair push with transactional email for full communication coverage. Mailgun's MENA-region routing reliably reaches UAE and KSA inboxes with a generous free tier.

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.