laravel-notification-channels/apn
Send push notifications to iOS, macOS, and watchOS from Laravel using Apple's APN service. This notification channel supports token and certificate authentication, HTTP/2, custom payloads, and easy integration with Laravel's notification system.
Architecture fit. The package is a thin, idiomatic Laravel notification channel — it slots into the framework's existing via() / toApn() contract rather than introducing a new subsystem. Architecturally that is the right shape: push becomes one channel among mail, database, and SMS in the notification layer you already own, and the vendor code stays behind an interface (the Notification class) instead of leaking into product code. The integration surface is small and well-bounded: a service provider, a config file, an ApnMessage payload builder, and a client that speaks APNs HTTP/2 with JWT provider-token auth.
Integration feasibility. Low-to-moderate. The prerequisites are concrete and enumerable: a .p8 key, Key ID, Team ID, bundle ID (apns-topic), and a sandbox/production flag from the Apple Developer portal. The smallest viable spike is a single on-demand notification from tinker against one device token — if it returns without throwing, credentials, topic, and endpoint are correct. That is a one-to-two-day task, not a sprint. The real feasibility question is not the package, it's the environment: you need a shared cache store (Redis/Memcached/database) to get the intended benefit of the 20-minute provider-token cache. A single-node file cache is functionally correct but forfeits token reuse across processes.
Technical risk. Ranked:
403 InvalidProviderToken — intermittent because it only fails inside the TTL window. This is the single biggest thing to verify in source before running two apps off one cache..p8 or Key ID without flushing the token cache means the revoked key keeps working until TTL expiry — an hour of confusing debugging.PayloadTooLarge on fat custom payloads. And "sent successfully" ≠ "shown" — permission denial and Focus mode mean push cannot be the sole mechanism for state the app depends on.Key questions.
device_tokens table, or are we starting from a single users.device_token column?Stack fit. Strong, conditional on Laravel/PHP. The value proposition here is framework integration — a dedicated push provider is the correct comparison if the backend is not Laravel. Within Laravel, the channel convention means push rides an existing notification epic rather than gating a release on a new subsystem.
Migration path. Two distinct paths:
ApnChannel::class to via(), implement routeNotificationForApn() and toApn(), smoke-test on-demand, then queue. Roughly one sprint including device-token plumbing and testing.toApn() method, keep one integration test that actually hits the sandbox, and retire the old client only after parity is confirmed. Retrofitting per-tenant credentials after the token cache is in play is where the 403 bugs live — decide multi-app architecture early, not after.Compatibility. The package supports both .p8 token auth and certificate-based setups, and both sandbox and production endpoints. The compatibility risk is concentrated in Laravel version behavior: the pre-6.4 pattern of caching the whole client breaks under Laravel 13's stricter cache controls, and config:cache in production is a real footgun — only env() calls inside config/apn.php survive the cache. Any env('APN_KEY_ID') read elsewhere returns null in production while working fine locally. Pin ^6.4.
Sequencing.
device_tokens table keyed by user, not a single column. You will outgrow a single column within a sprint, and you need rows to soft-delete when APNs reports Unregistered.config:cache behavior in staging.ShouldQueue, on its own queue.CouldNotSendNotification in failed(); soft-delete on Unregistered/BadDeviceToken, retry with backoff on 429/503/TooManyProviderTokenUpdates.Maintenance. The package itself is narrow and stable — APNs is a fixed protocol and the payload API is small. Maintenance burden concentrates in three places: Apple credential lifecycle (rotation, .p8 handling), device-token hygiene (pruning stale tokens), and Laravel framework upgrades (v6.4.0 was a Laravel 13 compliance change, not a feature). Treat credentials as an owned secret with a runbook, not a config detail. The .p8 is downloadable exactly once — store it outside the webroot at a durable path and reference the path, never inline the contents, because newlines get eaten and produce a 403 with no useful error.
Support. Low external support surface: MIT license, narrow scope, effectively one maintainer, zero dependents. This is not a vendor relationship. Budget for the possibility that a future Laravel release requires a patch you must write or fund. The mitigating factor is that the abstraction boundary is cheap to maintain — keep vendor classes out of product code via a base PushNotification or ApnMessage factory, and a package swap becomes a one-file change. Do not let product requirements creep toward segmentation, orchestration, A/B testing, or send-time optimization; this package sends pushes and will not grow into campaign tooling.
Scaling. The throughput story is the token cache. Ten thousand fan-out sends sharing one minted provider token instead of re-authenticating per send is the difference between a clean run and 429 TooManyProviderTokenUpdates. Two hard rules follow: resolve the client from the container rather than constructing it per send, and give push its own queue so a slow APNs HTTP/2 response cannot starve mail or webhooks. Set worker --timeout below the queue's retry_after. Also enforce the 4KB payload ceiling server-side — truncate body copy, send identifiers rather than serialized objects.
Failure modes. Enumerated by likelihood and consequence:
| Failure | Detection | Response |
|---|---|---|
Mangled .p8 (newlines eaten) |
403 on every send, no useful error | Store file path, not contents |
Unregistered / BadDeviceToken |
Exception from channel | Soft-delete token row, mark job success — never retry |
| Sandbox token sent to prod endpoint | BadDeviceToken |
Bind APN_PRODUCTION to environment; never copy dev tokens into fixtures |
| Cross-app token cache collision | Intermittent 403 inside 20-min window | Namespace cache store per app; verify key composition in source |
| Rotation without cache flush | Revoked key still works for 20 min | Flush token cache as a step in the rotation runbook |
429 / 503 / TooManyProviderTokenUpdates |
Exception | Retry with backoff |
config:cache nulls env outside config file |
Works locally, fails in prod | Confine all env() reads to config/apn.php |
PayloadTooLarge |
4KB rejection | Truncate body, send IDs |
Note the observability gap: setId() gives you an apns-id for log correlation, but it is not proof of display — permission denial and Focus mode mean push delivery is not a business-logic guarantee.
Ramp-up. Shallow. A Laravel-fluent engineer can produce a working transactional push in a day given valid credentials. The non-obvious learning is operational rather than syntactic: token cache behavior, error-class handling on the queue, and the sandbox/production token distinction. Mitigate by pairing the first integration with one real-device end-to-end verification in staging — Notification::fake() will happily validate a payload Apple rejects, and simulators cannot be pointed at your real APNs key. Budget half a day of iOS-side work too: ->category('MESSAGE') must match a UNNotificationCategory registered in the app, and deep-link handling needs client-side plumbing for custom payload keys.
How can I help you explore Laravel packages today?