aporat / push-notifications
PHP client for Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM): token-based auth, persistent HTTP/2, typed payloads and normalized responses
Requires
- php: ^8.4
- ext-curl: *
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.9 || ^8.0
- guzzlehttp/promises: ^2.0 || ^3.0
- guzzlehttp/psr7: ^2.6 || ^3.0
- psr/cache: ^2.0 || ^3.0
- psr/clock: ^1.0
- psr/http-message: ^2.0
- psr/log: ^3.0
Requires (Dev)
- laravel/pint: ^1.21
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHP client for Apple Push Notification service (APNs) and Firebase Cloud Messaging (FCM). Token-based authentication, one persistent HTTP/2 connection per service, typed payload builders that never guess, and one normalized response for both services so your code can drop dead tokens and retry transient failures without caring who answered.
Framework-free. The Laravel integration lives in aporat/laravel-push-notifications.
โจ Features
- APNs with the
.p8auth key: ES256 provider tokens signed throughext-openssl, reused within Apple's 20-to-60-minute window, optionally shared across workers through any PSR-6 cache - FCM HTTP v1 with a service account: the OAuth2 access token fetched from an RS256 assertion, cached until shortly before expiry, optionally shared through PSR-6
- Persistent HTTP/2. One connection per client, kept for the life of the process, with batches multiplexed over it. No connection per push
- Push types that mean what they say.
Message::alert(),::background(),::liveActivity(),::voip()and the rest fix theapns-push-type, derive theapns-topicsuffix and the default priority. Nothing is inferred from the payload, and a combination Apple would reject is refused before it is sent - Every documented field. The full
apsdictionary including Live Activity keys; the FCMnotification,data,android,apns,webpushandfcm_optionsblocks - One answer shape. Every push returns a
Responsewith a normalizedOutcome: delivered, unregistered, invalid target, invalid message, unauthorized, rate limited, unavailable. Apple's reason strings and Google's error codes are both kept on it. Connection failures are responses too; only configuration and message errors throw - Cross-platform
Notificationfor the common case: describe it once, render it for either service, and reach the full platform message through a callback when you need more - Testable. A
FakeSenderthat records messages, a frozen clock, and clients that accept any Guzzle client so aMockHandlerstands in for Apple and Google
๐ Installation
composer require aporat/push-notifications
Requires PHP 8.4+ with ext-curl built against nghttp2 (HTTP/2), ext-openssl and ext-json. The client checks for HTTP/2 support at construction and throws a ConfigurationException if curl lacks it:
php -r 'var_dump((bool) (curl_version()["features"] & CURL_VERSION_HTTP2));'
๐ APNs
use Aporat\PushNotifications\Apns\Alert; use Aporat\PushNotifications\Apns\Client; use Aporat\PushNotifications\Apns\Credentials; use Aporat\PushNotifications\Apns\Environment; use Aporat\PushNotifications\Apns\Message; use Aporat\PushNotifications\Apns\Payload; $client = new Client( new Credentials( teamId: 'ABCDE12345', keyId: 'XYZ9876543', privateKey: '/path/to/AuthKey_XYZ9876543.p8', // or the PEM text itself ), Environment::Production, ); $message = Message::alert($deviceToken, Payload::create() ->alert(Alert::create('New message', 'Alice sent you a photo')) ->badge(1) ->sound('default') ->threadId('chat-42') ->custom('chat_id', 42)) ->bundleId('com.example.app') ->collapseId('chat-42') ->expiresIn(3600); $response = $client->send($message); if ($response->tokenIsUnregistered()) { // 410 Unregistered or ExpiredToken: remove $deviceToken from your database. }
One client serves every app of the team. The message carries the bundle ID; the client holds the key and the connection. Build the client once and keep it: Apple binds a connection to the team that first pushes on it, asks providers to reuse connections for hours to days, and treats connection churn as abuse.
Push types
Each named constructor sets apns-push-type, derives the apns-topic from the bundle ID, and picks the priority Apple expects:
| Constructor | Topic | Default priority |
|---|---|---|
Message::alert() |
<bundle> |
10 |
Message::background() |
<bundle> |
5 (10 is refused) |
Message::liveActivity() |
<bundle>.push-type.liveactivity |
10 |
Message::voip() |
<bundle>.voip |
10 |
Message::complication() |
<bundle>.complication |
10 |
Message::fileProvider() |
<bundle>.pushkit.fileprovider |
10 |
Message::location() |
<bundle>.location-query |
10 |
Message::pushToTalk() |
<bundle>.voip-ptt |
10 |
Message::widgets() |
<bundle>.push-type.widgets |
10 |
Message::controls() |
<bundle>.push-type.controls |
10 |
Message::accessory() |
<bundle>.push-type.accessory |
10 |
Message::mdm($token, $topic) |
the MDM certificate's UID | 10 |
Override any of it with ->topic(), ->priority(), ->expiresAt(), ->neverStore(), ->collapseId() and ->id().
An alert with contentAvailable() stays an alert at priority 10. That hybrid push is a common need and a common thing for libraries to silently turn into a background push; here the type is yours.
Live Activities
$message = Message::liveActivity($activityToken, Payload::create() ->event('update') ->timestamp(time()) ->contentState(['status' => 'out_for_delivery', 'eta' => 12]) ->staleDate(time() + 600) ->alert(Alert::create('Your order is on its way'))) ->bundleId('com.example.app');
event() and timestamp() are required and checked; starts take attributes() as well. Broadcast channels are not covered yet (see below).
Provider tokens
The client mints an ES256 token on first use and reuses it for 50 minutes, inside Apple's rule of refreshing no more than every 20 and no less than every 60 minutes. An ExpiredProviderToken answer replaces the token and retries once; when many requests are in flight, the first rejection does the replacing and the rest reuse it, and a replacement another worker already wrote to the shared cache is adopted rather than minting one more.
To share one token across every worker that uses the key, pass a PSR-6 pool:
$client = new Client($credentials, Environment::Production, cache: $psr6Pool);
options also takes port (443 or 2197), timeout, connect_timeout, proxy, concurrency for sendMany(), and token_refresh_after in seconds.
Sandbox
Tokens from Xcode builds only exist on the sandbox gateway. $client->withEnvironment(Environment::Sandbox) gives you a second client on its own connection sharing the same provider token.
๐ฅ FCM
use Aporat\PushNotifications\Fcm\AndroidPriority; use Aporat\PushNotifications\Fcm\Client; use Aporat\PushNotifications\Fcm\Message; use Aporat\PushNotifications\Fcm\ServiceAccount; use Aporat\PushNotifications\Fcm\Target; $client = new Client(ServiceAccount::fromFile('/path/to/service-account.json')); $message = Message::to($registrationToken) ->notification('New message', 'Alice sent you a photo', image: 'https://cdn.example.com/photo.jpg') ->data(['chat_id' => 42]) ->priority(AndroidPriority::High) ->channelId('chat') ->badge(1) ->collapseKey('chat-42') ->ttl(3600); $response = $client->send($message);
ServiceAccount::fromArray() and ::fromJson() take the key file's contents, for a secrets manager; the constructor takes the pieces. Targets are a registration token, Target::fid() for a Firebase Installation ID, Target::topic() or Target::condition().
A data-only message is just ->data() without ->notification(). It wakes the app's messaging service in every app state and leaves the display to the app, which is the closest FCM comes to an APNs alert with content-available.
->android(), ->apns() and ->webpush() merge raw fields into those blocks for anything without a method. ->badge() and ->sound() set both the Android notification fields and the aps override, so an iOS device reached through FCM gets them too.
The access token is fetched from Google on first use and reused until five minutes before it expires. A 401 fetches a new one and retries once. Share it across workers with a PSR-6 pool through the cache argument, exactly as for APNs.
๐จ One notification, both services
use Aporat\PushNotifications\Notification; $notification = Notification::create('New collab request', 'Alice wants to work with you') ->badge(3) ->sound('default') ->data(['type' => 'collab', 'id' => 42]) ->collapseId('collab-42') ->ttl(3600); $apns->send($notification->toApns($device->apns_token, 'com.example.app')); $fcm->send($notification->toFcm($device->fcm_token));
toApns() renders an alert push; toFcm() a notification message, or a data-only one with ->dataOnly(). Only what both services share lives on Notification. For anything else, the callbacks hand you the rendered platform message with its whole API:
$notification ->apns(fn (Apns\Message $m) => $m->payload()->interruptionLevel(InterruptionLevel::TimeSensitive)) ->fcm(fn (Fcm\Message $m) => $m->channelId('collabs')->analyticsLabel('collab'));
๐ฌ Responses
Every send returns a Response. Nothing about the service's answer throws.
| Property | Meaning |
|---|---|
outcome |
The normalized Outcome (below) |
statusCode |
The HTTP status, 0 when the service was never reached |
reason |
The service's own code: Unregistered, BadDeviceToken, UNREGISTERED, QUOTA_EXCEEDED, โฆ |
id |
The apns-id, or the FCM message name |
message |
A human-readable failure description |
timestamp |
APNs 410 only: when the token died, in milliseconds |
retryAfter |
Seconds to wait, when the service sent Retry-After |
body, headers |
The decoded body and the headers worth keeping |
Outcome |
APNs | FCM | What to do |
|---|---|---|---|
Delivered |
200 | 200 | Nothing |
Unregistered |
410 Unregistered, ExpiredToken |
UNREGISTERED |
Delete the token |
InvalidTarget |
BadDeviceToken, DeviceTokenNotForTopic |
SENDER_ID_MISMATCH, a BadRequest on the token field |
Don't retry; the token is wrong for this app or environment |
InvalidMessage |
PayloadTooLarge, BadTopic, BadPriority, โฆ |
INVALID_ARGUMENT |
Fix the message |
Unauthorized |
403 provider-token and certificate errors, TopicDisallowed |
THIRD_PARTY_AUTH_ERROR, 401/403 |
Fix the credentials |
RateLimited |
429 | QUOTA_EXCEEDED, 429 |
Retry after retryAfter or a backoff |
Unavailable |
5xx, IdleTimeout, Shutdown, connection failures |
UNAVAILABLE, INTERNAL, 5xx, connection failures |
Retry with backoff |
Failed |
anything else | anything else, including a bare 404 | Look at reason |
$response->succeeded(), ->tokenIsUnregistered() and ->isRetryable() answer the three questions most code asks. Apple's do-not-retry list (BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered, PayloadTooLarge) is never retryable.
๐ฆ Batches
$responses = $client->sendMany([ 'alice' => $notification->toApns($aliceToken, 'com.example.app'), 'bob' => $notification->toApns($bobToken, 'com.example.app'), ]); foreach ($responses as $name => $response) { if ($response->tokenIsUnregistered()) { /* ... */ } }
Sends run concurrently over the one connection, up to the concurrency option (50 by default). Results have the same keys as the input. Every message is validated, and the provider or access token fetched, before the first one is sent, so an invalid message or a credential problem throws before anything leaves.
๐งช Testing
Hand your code a FakeSender wherever it expects a Contracts\Sender:
use Aporat\PushNotifications\Outcome; use Aporat\PushNotifications\Testing\FakeSender; $sender = new FakeSender(Outcome::Delivered); $sender->respondWith(fn (Message $m) => $m->target() === $deadToken ? Outcome::Unregistered : Outcome::Delivered); // ... run the code under test ... $sender->sent(); // every message, in order $sender->sentTo($token); // the messages for one target
The fake still validates each message, so a bad payload fails in tests the way it would in production. To test the clients themselves, pass a Guzzle client built on a MockHandler as $http, and a Clock\FrozenClock to control token lifetimes.
๐ Logging
Both clients take a PSR-3 logger. Every accepted push is logged at debug, every refused one at warning, with the target, status, reason and message ID. Device tokens are logged as-is; redact in your logger if that matters to you.
๐ง Not included (yet)
- APNs certificate-based authentication. Token auth covers every push type.
- APNs broadcast channel management for Live Activities (
api-manage-broadcast.push.apple.com). - The legacy FCM HTTP API and the removed batch endpoint. FCM v1 only.
๐ License
MIT. See LICENSE.