Sending iOS Push Notifications with APNs HTTP/2 in Laravel (Part 3 of 4)
Dinesh Wijethunga
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
- developer.apple.com β Certificates, Identifiers & Profiles β Keys.
- Create a new key, check Apple Push Notifications service (APNs).
- Download the
.p8file (only available once β save it). - 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 IDcom.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-type | apns-priority | What it does |
|---|---|---|
alert | 10 | Shows a visible notification (title + body) |
background | 5 | Silently wakes the app β user sees nothing. Must use priority 5 or iOS drops it. |
liveactivity | 10 or 5 | Updates 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.
Reviews & Ratings
Sign in to leave a review.
