zendframework/zendservice-apple-apns
Zend Framework service for Apple Push Notification Service (APNs). Send push notifications to iOS devices from PHP using certificate-based connections, with support for Apple’s feedback service.
Before writing a line of code, confirm you actually need this package: ZendService_Apple_Apns speaks Apple's legacy binary provider protocol (gateway.push.apple.com:2195) and the feedback service (feedback.push.apple.com:2196). Apple retired both, so the package can no longer deliver a notification to a real device. In practice you install it for exactly two reasons: you are maintaining a legacy app that already depends on it, or you are reading its payload-building code as a reference while porting to HTTP/2. If you want to send pushes today, stop and go straight to curl with CURL_HTTP_VERSION_2_0 against api.push.apple.com with a JWT token (or a maintained library such as edamov/pushok).
Assuming legacy maintenance, the minimum path is:
composer require zendframework/zendservice-apple-apns
Composer will flag the package as abandoned. Check the resolved PHP constraint with composer show zendframework/zendservice-apple-apns — the 2019 release targets PHP 5.6/7.x, so on PHP 8 you will likely need a fork or a patched constraint rather than --ignore-platform-reqs.
Next, produce a PEM containing both the certificate and the private key (the client passes it to stream_socket_client as local_cert):
openssl pkcs12 -in apns.p12 -out apns.pem -nodes -clcerts # -nodes = no passphrase
Then the first use case, a single alert:
use ZendService\Apple\Apns\Client\Message as ApnsClient;
use ZendService\Apple\Apns\Message;
use ZendService\Apple\Apns\Response\Message as MessageResponse;
$client = new ApnsClient();
$client->open(__DIR__ . '/certs/apns.pem', ApnsClient::PRODUCTION, $passphrase ?: null);
$message = new Message();
$message->setToken('a1b2c3…64 hex chars…');
$message->setAlert('Your order shipped');
$message->setBadge(3);
$message->setSound('default');
$response = $client->send($message);
if ($response->getCode() !== MessageResponse::RESULT_OK) {
error_log('APNS write failed: ' . $response->getCode());
}
$client->close();
Look first at ZendService\Apple\Apns\Client\AbstractClient (socket setup, environment map), Message (payload assembly), and Message\Alert (localized/action alerts). Everything else is thin.
Wrap the client in a small service, not in controllers. The socket is opened per open() call and TLS handshake dominates latency, so create one client per job/request and reuse send() for many messages:
class ApnsNotifier
{
public function __construct(private string $pem, private ?string $pass, private string $env) {}
public function sendMany(array $pushes): array
{
$client = new ApnsClient();
$client->open($this->pem, $this->env, $this->pass);
$results = [];
try {
foreach ($pushes as $token => $payload) {
$message = new Message();
$message->setToken($token)
->setAlert($payload['body'] ?? null)
->setBadge((int) ($payload['badge'] ?? 0))
->setSound($payload['sound'] ?? 'default')
->setCustom($payload['data'] ?? []);
$results[$token] = $client->send($message)->getCode();
}
} finally {
$client->close();
}
return $results;
}
}
Queue the work, not the socket. Push sending belongs in a queued job (dispatch(new SendPush($userId, $payload))) so an APNS hiccup cannot block an HTTP request. Because there is no per-message delivery feedback for the format this library uses, your job only records "write attempted"; delivery truth comes later from token pruning.
Localized and action alerts use the Alert object instead of a string:
use ZendService\Apple\Apns\Message\Alert;
$alert = (new Alert())
->setBody('Your order shipped')
->setLocKey('SHIP_BODY')
->setLocArgs(['1234'])
->setActionLocKey('SHIP_ACTION');
$message->setAlert($alert);
Silent/background refresh — set content-available and omit alert/badge/sound:
$message->setContentAvailable(1);
Custom data goes through setCustom([...]) and is merged next to aps. Never put an aps key in there; it will collide with the generated dictionary.
Feedback pruning is the only reliable hygiene mechanism the package offers. Run it as a scheduled command and delete the returned tokens from your device_tokens table:
$feedback = new Feedback();
$feedback->open($pem, Feedback::PRODUCTION, $pass);
foreach ($feedback->feedback() as $record) {
DeviceToken::where('token', $record->getToken())->delete();
}
$feedback->close();
Testing. Both clients extend AbstractClient, and there are MessageInterface/client interfaces, so either inject a fake implementation of MessageInterface (only getToken() and getPayload() matter) or subclass the client to point at a local TLS mock instead of the Apple host. Assert on getPayload() JSON — that is where every bug in this library actually lives, and it is testable without a socket.
json_encode of a fat custom array silently blows past it — validate strlen($message->getPayload()) before sending and truncate or drop fields.send() reports RESULT_OK when fwrite succeeded, not when Apple accepted the token. Invalid tokens produce no error, so a RESULT_OK is meaningless for retry logic; only prune via feedback.<, >, and spaces from device tokens and lowercase them before setToken(). A non-hex string produces a malformed packet that fails silently.setBadge('3') can serialize as "3", which Apple rejects; cast it. setBadge(0) correctly clears the badge — the null check inside the payload builder distinguishes 0 from unset.RuntimeException from open(). Check openssl_get_cert_locations() and your openssl.cafile before blaming the certificate..pem is date-bound and per-environment; sandbox certs cannot talk to production and vice versa. Wire a monitor that parses openssl_x509_parse() and alerts 30 days out..p12.close() leaks a file descriptor per FPM request; conversely, opening inside a loop will make a 10k-token broadcast take minutes. Batch and close.AbstractClient and override the environment map rather than hard-coding stream_socket_client calls in your own class.How can I help you explore Laravel packages today?