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.
Day one is configuration, not code. The channel is thin — almost all of the work you'll do is in setup and in what you put into toApn().
composer require laravel-notification-channels/apn
php artisan vendor:publish --provider="NotificationChannels\Apn\ApnServiceProvider"
Populate config/apn.php and .env with the three things from the Apple Developer portal (Certificates, Identifiers & Profiles → Keys): Key ID, Team ID, and the .p8 file. Apple only lets you download the .p8 once — drop it in storage/app/apn/AuthKey_ABC123.p8 (outside the webroot) and reference the path in env, never the contents. Add your bundle ID (this becomes the apns-topic) and the sandbox/production flag.
Two pre-flight checks before writing product code:
^6.4 and don't assume older majors behave the same.Where to look first: config/apn.php, then the ApnMessage class in vendor/laravel-notification-channels/apn/src/. Those public methods are the payload API — the README example is representative, not exhaustive.
First use case — a transactional alert. Add the channel to via(), a route method to the notifiable, and a toApn():
// app/Models/User.php
public function routeNotificationForApn()
{
return $this->device_token; // nullable
}
// app/Notifications/OrderShipped.php
public function via($notifiable)
{
return [ApnChannel::class];
}
public function toApn($notifiable)
{
return ApnMessage::create()
->title('Order shipped')
->body("Order #{$this->order->id} is on its way.")
->badge(1)
->sound('default')
->setId((string) Str::uuid()); // apns-id for log correlation
}
Smoke-test from tinker without touching a real notifiable model, using an on-demand notification:
Notification::route('apn', $deviceToken)->notify(new OrderShipped($order));
If that returns without throwing, credentials, topic, and endpoint are correct. Do this before building device tables, fan-out jobs, or payload abstractions — it's the cheapest possible spike.
Route to a device table, not a column. users.device_token survives about a sprint. Real apps have iPhone + iPad + reinstalls, and you need a row you can delete when APNs says Unregistered.
public function routeNotificationForApn()
{
return $this->deviceTokens()->pluck('token');
}
Verify fan-out against your installed version; if it only accepts a single token, loop inside a queued job — but still keep the table.
Queue by default. Push is an outbound HTTP/2 call to a third party. Implement ShouldQueue (or $user->notify() inside a job), give push its own queue so a slow APNs response can't starve mail or webhooks, and keep the worker --timeout below retry_after. This is also where the 20-minute token cache pays off: a 10k-token fan-out shares one minted provider token instead of hammering Apple's auth endpoint.
Keep toApn() as the single payload surface. The channel is an implementation detail; express intent as a notification class. If you have several, put shared shape (badge policy, sound, category constants) in a base PushNotification or a small ApnMessage factory. Vendor classes should not leak into controllers or services — that keeps a future package swap a one-file change.
Categories and custom keys for actionable + deep-linked pushes.
ApnMessage::create()
->title('New message')
->category('MESSAGE') // must match UNNotificationCategory in iOS code
->custom('conversation_id', $id) // structured, not stringified into body
->contentAvailable(true); // silent/data-only when there's no alert
Treat silent pushes as best-effort — iOS budgets background pushes and drops them under low-power conditions. Never make them the only mechanism for state the app depends on.
Testing is two-layered. Notification::fake() intercepts at the notification layer:
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($notification, $channels) {
return in_array(ApnChannel::class, $channels);
});
Then unit-test toApn() in isolation by asserting on the returned ApnMessage's public accessors. Keep one integration path — staging device or a manual checklist — that actually hits the sandbox endpoint. Fakes will happily validate a payload Apple rejects.
Handle token failures in the job, not by ignoring errors. Catch NotificationChannels\Apn\Exceptions\CouldNotSendNotification in failed(): on Unregistered / BadDeviceToken, soft-delete the token row and mark the job successful — retrying will never succeed. Retry with backoff on 429, 503, and TooManyProviderTokenUpdates.
Multiple apps or environments. Config is global. A family of iOS apps means either separate deployments or building the client with per-app credentials and injecting it rather than resolving the container singleton. Decide early — retrofitting per-tenant credentials after the cache is in play is how 403 InvalidProviderToken bugs are born.
.p8 or Key ID needs a token-cache flush in the same runbook step, or the revoked key keeps working until TTL expiry and you burn an hour debugging "revoked but functional.".p8 is the most common "it just doesn't work." It must retain -----BEGIN PRIVATE KEY----- / -----END PRIVATE KEY----- and a trailing newline. Inline env vars eat newlines; store a path. Symptom: 403 on every send, no useful error.new the client per send. Resolve from the container so the token cache is actually shared. Constructing one inside a loop re-mints tokens — exactly how teams earn 429 TooManyProviderTokenUpdates.config:cache is a real footgun. Only env() calls inside config/apn.php survive the cache. Calling env('APN_KEY_ID') from a service provider works locally and returns null in production.BadDeviceToken. Bind APN_PRODUCTION to your environment; never copy a dev token into a staging fixture.PayloadTooLarge. Truncate server-side and send IDs, not serialized objects.apns-id from setId() is for log correlation, not proof of display.xcrun simctl push <device> <bundle-id> payload.apns with a hand-written payload for client work; real device for end-to-end. Simulators can't use your APNs key.^6.4, keep the channel behind your own notification abstraction, and know your exit plan. It sends pushes — it does not segment, orchestrate, or report; resist that scope creep.How can I help you explore Laravel packages today?