duccio/apns-php
A PHP library for sending push notifications to iOS and macOS via Apple Push Notification service (APNs). Supports HTTP/2 and legacy binary protocols, token and certificate authentication, batch sending, error handling, and feedback.
1. Verify PHP's cURL has HTTP/2 before anything else.
The v2.0.0-alpha release speaks HTTP/2, so a cURL build without nghttp2 will fail in ways that look like network problems:
php -r 'var_dump((bool)(curl_version()["features"] & CURL_VERSION_HTTP2));'
curl --version | grep -i nghttp2
If that's false/absent, stop — upgrade or rebuild PHP's cURL (apt install php-curl against a libcurl with nghttp2) before touching the library.
2. Install the alpha explicitly.
composer require "duccio/apns-php:2.0.0-alpha"
The earlier tagged release speaks Apple's binary protocol, which Apple shut down on March 31, 2021 — it cannot deliver anything. This alpha is therefore the only usable version in the package, not an optional upgrade. Pin the exact version (pre-releases are opt-in via an exact constraint; no minimum-stability change needed) and treat it as pre-release software: budget time for a fork or a maintained HTTP/2 client if you need stability guarantees.
3. Generate a new certificate — you cannot reuse the old one.
The release notes are explicit: the binary-era certificate must be regenerated. Create an Apple Push Services certificate (production) and/or an Apple Development certificate (sandbox), covering both environments if you want one credential. Export as .p12, convert to PEM, and bundle Apple's root CA:
openssl pkcs12 -in apns.p12 -out apns.pem -nodes -clcerts
4. Confirm the installed code targets HTTP/2.
Open vendor/duccio/apns-php/ApnsPHP/Abstract.php and check for https://api.push.apple.com / https://api.sandbox.push.apple.com. Also eyeball how the connection is opened and closed — the transport changed, so the failure semantics of connect()/send() may have shifted.
5. Send your first push.
The public API is unchanged from the pre-2.0 examples:
require __DIR__.'/vendor/autoload.php';
$push = new ApnsPHP_Push(
ApnsPHP_Abstract::ENVIRONMENT_SANDBOX,
__DIR__.'/certs/apns-dev.pem'
);
$push->setProviderCertificatePassphrase('your-passphrase');
$push->setRootCertificationAuthority(__DIR__.'/certs/apple-root.pem');
$push->connect();
$msg = new ApnsPHP_Message($deviceToken); // 64 hex chars, no spaces/brackets
$msg->setTopic('com.example.app'); // HTTP/2 sends this as apns-topic
$msg->setBadge(1);
$msg->setSound('default');
$msg->setText('Hello from ApnsPHP');
$push->add($msg);
$push->send();
$push->disconnect();
foreach ($push->getErrors() as $err) { /* [$message, $code, $reason] */ }
Where to look first: ApnsPHP/Push.php and ApnsPHP/Message.php are still the 95% files, but ApnsPHP/Abstract.php now holds the HTTP/2 transport and is worth reading once. The examples/ directory predates this release — treat those scripts as a sketch of the API surface, not as verified-working code, and check them against the installed source.
First real use case: unchanged — one CLI script, one sandbox token from didRegisterForRemoteNotificationsWithDeviceToken, end-to-end, before you build anything on top of an alpha.
Wrap the client in a service. ApnsPHP_Push is still stateful (connection + accumulated errors). One long-lived instance per worker/batch beats constructing per notification:
final class ApnsSender
{
public function __construct(private ApnsPHP_Push $push) {}
/** @param ApnsPHP_Message[] $messages */
public function send(array $messages): array
{
$this->push->connect();
foreach ($messages as $m) { $this->push->add($m); }
$this->push->send();
$this->push->disconnect();
return $this->push->getErrors();
}
}
Set the topic explicitly on every message. Under HTTP/2 the topic is a request header (apns-topic), not just payload metadata. If it's missing/mismatched you get MissingTopic/TopicDisallowed rather than a delivered push. Don't infer it from the certificate — pass it.
Batch, then correlate errors. add() queues, send() flushes. After every send(), walk getErrors() ([ApnsPHP_Message $message, int $code, string $reason]) and map $message->getRecipient() back to your records.
Split transient from permanent. The codes that matter haven't changed: 3/8/410 → delete the token; 429/503 → back off and retry; 10 (connection shut down) → reconnect and re-send the failed subset; PayloadTooLarge/BadDeviceToken → fix data, never retry. A retry wrapper that only re-queues transient failures is mandatory.
Feed from a queue. Same architecture as before and more valuable now: enqueue notification rows, have a worker build messages, send, and update rows from getErrors(). Keeps APNs latency out of the request path and gives you a natural backoff point.
Use the payload helpers. setText, setBadge, setSound, setCategory, setContentAvailable, setMutableContent, setCustomProperty still map to the standard Apple dictionary. Set setExpiry() and setPriority() (10 immediate, 5 power-saving) explicitly — the defaults assume a foreground push — and use setContentAvailable(1) with no alert text for silent pushes.
Logging. setLogger(new ApnsPHP_Log_Embedded()) + setLogLevel(ApnsPHP_Abstract::LOG_LEVEL_INFO) for development; swap for a redacting PSR-3 handler (or silence it) in production. Verbose logs can expose certificate paths and request details.
Certificate auth only. The library remains certificate-based; token auth (.p8 + JWT) is still not first-class. The passphrase and root-CA setters must be called before connect().
Skip the feedback service. ApnsPHP_Feedback was a binary-protocol concept. On HTTP/2, invalid tokens arrive per-message as 410/3 — make that your cleanup signal instead of a nightly cron.
This is an alpha, and the release notes document almost nothing. They state HTTP/2 support and the certificate requirement; they do not describe API, exception, or behaviour changes. Diff the installed source against your assumptions, read ApnsPHP/Abstract.php, and smoke-test every code path you rely on. Expect churn between alphas — pin the version and upgrade deliberately.
No HTTP/2 in cURL = silent, confusing failure. Confirm nghttp2 at deploy time on every host and CI image, not just locally.
Old certificates will not work. Regenerate in the developer portal. Using a binary-era cert against the HTTP/2 endpoint produces connection or BadCertificate errors even though the file loads fine. Check the environment too: openssl x509 -in apns.pem -text -noout — a sandbox cert cannot send to ENVIRONMENT_PRODUCTION or vice versa.
Payload limit is 4 KB now. A long setText() plus custom data can exceed it, and the rejection surfaces as a generic error rather than a local exception. Measure the encoded payload before add().
Error accumulation. getErrors() returns everything since construction. In long-running workers, build a fresh ApnsPHP_Push per batch or clear the internal list — otherwise error counts grow unbounded and stale errors get reprocessed.
Batch size still matters. HTTP/2 multiplexes, but don't assume send() succeeded without an exception; 10 (connection shutdown) still appears if you pipeline too aggressively. Chunk in the low hundreds, reconnect, retry the affected messages.
Bundle the Apple root CA. Without setRootCertificationAuthority() you'll hit cURL error 60 wherever the host trust store lacks Apple's CA.
PHP 8 compatibility is still unproven. The codebase lineage is old; watch for deprecation warnings around dynamic properties and nullable parameters, and test on the exact PHP version you deploy.
Device token format. Raw 64-char hex, no <, >, or spaces. The bracketed form from didRegisterForRemoteNotificationsWithDeviceToken yields BadDeviceToken.
Throw-vs-return, unchanged. Connection-level failures throw ApnsPHP_Exception; per-message failures only appear in getErrors(). Wrap in try/catch and check errors — either alone misses failures.
Old-style class names, not PSR-4. ApnsPHP_Push, ApnsPHP_Message, classmap-autoloaded. Alias them (use ApnsPHP_Push as Push;) in namespaced code; PSR-4 tooling and IDE autocomplete won't find them.
Token auth is still the gap that makes teams leave. If a security review mandates .p8 keys, plan for a fork or a small cURL-based HTTP/2 client rather than waiting on this package.
How can I help you explore Laravel packages today?