DEV Community

Cover image for Push notifications on iOS without Firebase: talking to APNs directly from Laravel
Guppylab
Guppylab

Posted on

Push notifications on iOS without Firebase: talking to APNs directly from Laravel

Most push tutorials for iOS start with "add the Firebase SDK to your app". It works, but it means shipping Google's SDK inside your iOS binary just to forward a message to Apple, and keeping a second service configured for every app.

Your Laravel backend can talk to Apple Push Notification service (APNs) directly. All it takes is a .p8 key, a short-lived JWT and one HTTP/2 request. This post walks through the whole thing, including the two mistakes that cost the most time: the wrong token type and the wrong environment.

What you need

  • An Apple Developer account
  • Your app's bundle ID (for example com.acme.app)
  • An APNs .p8 key, its Key ID and your Team ID
  • The raw APNs device token from the iOS app (a hex string, more on that below)

1. Create the .p8 key

In the Apple Developer portal, go to Certificates, Identifiers & Profiles โ†’ Keys, click +, give the key a name and enable Apple Push Notifications service (APNs). Register it and download the .p8 file.

You can only download it once, so store it somewhere safe. Write down:

  • the Key ID shown on the key page
  • your Team ID (top right of the portal, or under Membership)

Unlike the old .p12 certificates, the key does not expire every year. If the portal asks which environment the key is for, pick Sandbox & Production so the same key works for development builds and the App Store.

Keep the file out of public/ and out of git, for example in storage/app/private/apns/AuthKey.p8.

2. Configuration

// config/services.php
'apns' => [
    'key_id' => env('APNS_KEY_ID'),
    'team_id' => env('APNS_TEAM_ID'),
    'bundle_id' => env('APNS_BUNDLE_ID'),
    'key_path' => env('APNS_KEY_PATH', storage_path('app/private/apns/AuthKey.p8')),
    'production' => env('APNS_PRODUCTION', false),
],
Enter fullscreen mode Exit fullscreen mode
APNS_KEY_ID=ABC123DEFG
APNS_TEAM_ID=TEAM123456
APNS_BUNDLE_ID=com.acme.app
APNS_PRODUCTION=false
Enter fullscreen mode Exit fullscreen mode

The JWT is signed with ES256. Instead of converting OpenSSL's DER signature by hand, use firebase/php-jwt (just the JWT library, nothing to do with Firebase itself):

composer require firebase/php-jwt
Enter fullscreen mode Exit fullscreen mode

3. The client

<?php

namespace App\Services\Apns;

use Firebase\JWT\JWT;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class ApnsClient
{
    public function send(string $deviceToken, string $title, string $body, array $data = []): Response
    {
        $host = config('services.apns.production')
            ? 'https://api.push.apple.com'
            : 'https://api.sandbox.push.apple.com';

        return Http::withOptions(['version' => 2.0])
            ->withHeaders([
                'authorization' => 'bearer '.$this->providerToken(),
                'apns-topic' => config('services.apns.bundle_id'),
                'apns-push-type' => 'alert',
                'apns-priority' => '10',
            ])
            ->post("{$host}/3/device/{$deviceToken}", [
                'aps' => [
                    'alert' => ['title' => $title, 'body' => $body],
                    'sound' => 'default',
                ],
                ...$data,
            ]);
    }

    public function forgetProviderToken(): void
    {
        Cache::forget('apns.provider_token');
    }

    private function providerToken(): string
    {
        return Cache::remember('apns.provider_token', now()->addMinutes(50), fn () => JWT::encode(
            ['iss' => config('services.apns.team_id'), 'iat' => time()],
            file_get_contents(config('services.apns.key_path')),
            'ES256',
            config('services.apns.key_id'),
        ));
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details worth knowing:

  • HTTP/2 is required. APNs does not speak HTTP/1.1. ['version' => 2.0] tells Guzzle to use it, but only if PHP's cURL extension was built with HTTP/2 support (the nghttp2 library). Most recent systems have it; older shared hosting and some minimal Docker images don't. Check on the server that will send the pushes:
  php -r 'var_dump((bool) (curl_version()["features"] & CURL_VERSION_HTTP2));'
Enter fullscreen mode Exit fullscreen mode

bool(true) means you're good. If it prints false, install a cURL/libcurl build with nghttp2 (or use a PHP image that ships one) before going further.

  • The provider token is cached. Apple rejects tokens older than an hour and also complains if you generate new ones too often. Caching for 50 minutes keeps you inside both limits.
  • apns-topic is your bundle ID. A wrong topic is rejected even if everything else is right.
  • Custom data goes next to aps, not inside it. The whole payload must stay under 4 KB.

4. Send from a queued job

Don't call APNs inside the request that triggered the notification. The user would wait for a round trip to Apple, and a slow or failing response would slow down your app. Put the send in a job, one job per device, so a bad token never blocks the others:

<?php

namespace App\Jobs;

use App\Models\DeviceToken;
use App\Services\Apns\ApnsClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use RuntimeException;

class SendPushNotification implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public function __construct(
        public string $deviceToken,
        public string $title,
        public string $body,
        public array $data = [],
    ) {}

    public function handle(ApnsClient $apns): void
    {
        $response = $apns->send($this->deviceToken, $this->title, $this->body, $this->data);

        if ($response->successful()) {
            return;
        }

        // Rate limited, or APNs is having a moment: try again later.
        if (in_array($response->status(), [429, 500, 503])) {
            $this->release($this->attempts() * 30);

            return;
        }

        $reason = $response->json('reason');

        match ($reason) {
            // The app was uninstalled or the token is no longer valid: stop sending to it.
            'Unregistered' => DeviceToken::where('token', $this->deviceToken)->delete(),

            // Usually a sandbox/production mismatch, see below. Don't delete the token yet.
            'BadDeviceToken' => logger()->warning('APNs BadDeviceToken', ['token' => $this->deviceToken]),

            // The cached JWT is too old: drop it and retry with a freshly signed one.
            'ExpiredProviderToken' => $this->retryWithFreshToken($apns),

            // Wrong Key ID, Team ID, bundle ID or key file. Retrying won't help; a human will.
            default => $this->fail(new RuntimeException("APNs error: {$reason}")),
        };
    }

    private function retryWithFreshToken(ApnsClient $apns): void
    {
        $apns->forgetProviderToken();
        $this->release(1);
    }
}
Enter fullscreen mode Exit fullscreen mode

DeviceToken stands for wherever you store your users' tokens. Dispatching is one line, and the request returns immediately:

SendPushNotification::dispatch($deviceToken, 'Order shipped', 'Order #1234 is on its way.', ['order_id' => 1234]);
Enter fullscreen mode Exit fullscreen mode

The job reads every answer from APNs and acts on it: retries with a backoff on 429 and 5xx, deletes tokens that are gone for good, refreshes an expired JWT, and fails loudly on configuration errors instead of retrying them five times.

The two mistakes that cost the most time

Wrong token type

APNs needs the raw APNs device token: 64 hex characters. If your iOS app already uses the Firebase SDK, the token you've been storing is probably an FCM registration token, and APNs will reject it.

In native Swift, the raw token is the Data you get in didRegisterForRemoteNotificationsWithDeviceToken, converted to hex:

let token = deviceToken.map { String(format: "%02x", $0) }.joined()
Enter fullscreen mode Exit fullscreen mode

Wrong environment

A token issued for the sandbox only works on api.sandbox.push.apple.com, and a production token only on api.push.apple.com. Send to the other one and you get BadDeviceToken.

As a rule of thumb, builds you run from Xcode are sandbox, and TestFlight and App Store builds are production. But the entitlement that decides it lives in the signed binary, and with automatic signing it may not be what you think. Check it instead of guessing:

codesign -d --entitlements - /path/to/YourApp.app | grep -A1 aps-environment
Enter fullscreen mode Exit fullscreen mode

development means sandbox, production means production. Point APNS_PRODUCTION at whatever the binary says.

Wrapping up

That's the whole integration: one key, one cached JWT, one HTTP/2 request inside a queued job, and a match on the reason when something goes wrong. No Firebase SDK in your iOS app, and no second dashboard to keep in sync.

If you're building with NativePHP Mobile, I packaged the app side of this as a plugin, Push Direct: it registers with APNs directly on iOS (FCM on Android), hands you the raw device token, and delivers the payload, the tap and the cold start to your Livewire components. Full disclosure, it's a paid plugin I built. Everything in this post works without it.

Top comments (0)