DEV Community

Cover image for Push notifications the Laravel way: a custom APNs notification channel
Guppylab
Guppylab

Posted on

Push notifications the Laravel way: a custom APNs notification channel

In the first part we sent iOS push notifications straight to Apple: a .p8 key, a cached JWT, one HTTP/2 request inside a queued job. It works, but every place that sends a push has to know about tokens, devices and that job.

Laravel already has a better shape for this. Mail, SMS and Slack all go through notifications, and push can too. By the end of this post, this is all it takes:

$user->notify(new OrderShipped($order));
Enter fullscreen mode Exit fullscreen mode

The notification lands on every iPhone the user is signed in on, a dead token cleans itself up, and a tap opens /orders/1234. No Firebase, no package. I ran it end to end on a real iPhone before writing this, and the channel, payload and endpoint tests below are the ones from that project.

What we're building

  • a device_tokens table, because people have more than one device
  • an endpoint the app calls to register its token
  • one APNs environment per token, so a development build and a store build can share a backend
  • an ApnsMessage, the push equivalent of a MailMessage
  • an ApnsChannel that turns one notification into one queued job per device
  • tests at three levels, none of which talk to Apple

ApnsClient and the SendPushNotification job come from part 1, with one small change in step 3.

1. Store one token per device

Schema::create('device_tokens', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('token')->unique();
    $table->string('environment')->nullable(); // 'sandbox' or 'production'
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode
class DeviceToken extends Model
{
    protected $fillable = ['token', 'environment', 'user_id'];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
Enter fullscreen mode Exit fullscreen mode

On the User, the relationship plus the method the notification system calls to find where an apns notification should go:

public function deviceTokens(): HasMany
{
    return $this->hasMany(DeviceToken::class);
}

/** Every APNs token this user registered, one per device. */
public function routeNotificationForApns(): array
{
    return $this->deviceTokens()->pluck('token')->all();
}
Enter fullscreen mode Exit fullscreen mode

2. Let the app register its token

When the app gets its raw APNs token (64 hex characters, see part 1 for why it must not be an FCM token), it posts it here, together with the APNs environment the build uses (more on that in step 3):

Route::post('/device-tokens', function (Request $request) {
    $data = $request->validate([
        'token' => ['required', 'string', 'regex:/^[0-9a-f]{64}$/i'],
        'environment' => ['nullable', 'in:sandbox,production'],
    ]);

    // A phone keeps its token when someone else signs in on it, so the token
    // moves to the new user instead of being added twice.
    DeviceToken::updateOrCreate(
        ['token' => strtolower($data['token'])],
        ['user_id' => $request->user()->id, 'environment' => $data['environment'] ?? null],
    );

    return response()->noContent();
})->middleware('auth:sanctum');
Enter fullscreen mode Exit fullscreen mode

That comment is the whole point of the endpoint. The version most of us write first is $request->user()->deviceTokens()->firstOrCreate([...]). It works until two people share a phone: the second sign-in hits the unique index and the endpoint answers 500, or, without the index, the token belongs to both accounts and one person gets the other's notifications. The token identifies the device, not the user, so it is the key you look up by.

3. Sandbox and production, side by side

Part 1 picked the APNs host from one setting, APNS_PRODUCTION. That holds while every phone runs the same kind of build. It stops holding the day you have a development build on your own phone and the App Store build on your users' phones, both registering with the same backend: a sandbox token only works on api.sandbox.push.apple.com, a production token only on api.push.apple.com, and whichever way the switch points, one group gets BadDeviceToken. Eusebiu Balan pointed this out in the comments on part 1, and he's right: the environment belongs to the token, so it's stored next to it.

The client takes the environment as an argument and only falls back to the setting when it has none:

public function send(string $deviceToken, string $title, string $body, array $data = [], ?string $environment = null): Response
{
    // The token's own environment wins; APNS_PRODUCTION is the fallback.
    $production = $environment ? $environment === 'production' : config('services.apns.production');

    $host = $production
        ? 'https://api.push.apple.com'
        : 'https://api.sandbox.push.apple.com';

    // ...the rest is part 1, unchanged
}
Enter fullscreen mode Exit fullscreen mode

And the job looks it up when it runs:

$environment = DeviceToken::where('token', $this->deviceToken)->value('environment');

$response = $apns->send($this->deviceToken, $this->title, $this->body, $this->data, $environment);
Enter fullscreen mode Exit fullscreen mode

Looking it up in the job, instead of passing it through the channel, keeps the channel and routeNotificationForApns() as plain lists of tokens. A token that isn't in the table (an on-demand send, see step 6) or was registered without an environment simply gets the old behaviour.

The value itself has to come from the app, because only the signed build knows it: the aps-environment entitlement, development for sandbox and production for TestFlight and the App Store. Builds from Xcode carry it in their embedded provisioning profile; store builds don't have one and are always production.

4. The message

namespace App\Notifications\Messages;

class ApnsMessage
{
    public function __construct(
        public string $title,
        public string $body = '',
        public array $data = [],
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

$data is custom payload. It ends up next to aps, not inside it, which is where your app (and the deep link handling in it) will look.

5. The channel

A notification channel is any class with a send($notifiable, $notification) method:

namespace App\Notifications\Channels;

use App\Jobs\SendPushNotification;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Arr;

class ApnsChannel
{
    public function send(object $notifiable, Notification $notification): void
    {
        $tokens = Arr::wrap($notifiable->routeNotificationFor('apns', $notification));

        if ($tokens === []) {
            return;
        }

        $message = $notification->toApns($notifiable);

        // One job per device: a dead token or a 503 retries that device only,
        // never re-sends to the phones that already got it.
        foreach ($tokens as $token) {
            SendPushNotification::dispatch($token, $message->title, $message->body, $message->data);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two decisions in there are worth calling out.

The channel doesn't call Apple. It only fans out. The tempting alternative is to make the notification ShouldQueue and loop over the devices inside one job. Then the third of four devices gets a 503, the job retries, and devices one and two receive the same push again. With one job per device, each retry is scoped to the device that failed, and the per-reason handling from part 1 (delete on Unregistered, refresh the JWT on ExpiredProviderToken, back off on 429 and 5xx) keeps working as is.

Arr::wrap lets the same channel take a single token, which is what you get from an on-demand route (below).

Register it under a name, in AppServiceProvider::boot():

Notification::extend('apns', fn ($app) => $app->make(ApnsChannel::class));
Enter fullscreen mode Exit fullscreen mode

You could return ApnsChannel::class from via() instead, but a name is what makes routeNotificationForApns() and Notification::route('apns', ...) line up.

6. A notification

class OrderShipped extends Notification
{
    public function __construct(public Order $order) {}

    public function via(object $notifiable): array
    {
        return ['apns'];
    }

    public function toApns(object $notifiable): ApnsMessage
    {
        return new ApnsMessage(
            title: "Order #{$this->order->number} shipped",
            body: 'Arriving tomorrow. Tap to track.',
            data: ['link' => "/orders/{$this->order->number}"],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

And now it's the line from the top:

$user->notify(new OrderShipped($order));
Enter fullscreen mode Exit fullscreen mode

Because it's an ordinary notification, everything else Laravel does with notifications comes for free: via() can return ['apns', 'mail'], you can decide per user ($notifiable->wantsPush ? ['apns'] : ['mail']), and on-demand sending works without a user at all, which is handy for internal alerts and for trying a token out:

Notification::route('apns', $token)->notify(new OrderShipped($order));
Enter fullscreen mode Exit fullscreen mode

Notice the notification itself is not ShouldQueue. All the channel does synchronously is one query and a few dispatch calls; the slow part, talking to Apple, is already queued per device.

7. Test it without Apple

Three levels, each one catching a different kind of mistake.

In your application's own tests, you usually only care that the right notification went to the right user (the route here stands for wherever your app ships an order):

Notification::fake();

$this->post("/orders/{$order->id}/ship");

Notification::assertSentTo($order->user, OrderShipped::class);
Enter fullscreen mode Exit fullscreen mode

For the channel, fake the queue and check the fan-out:

public function test_it_queues_one_job_per_device(): void
{
    Queue::fake();

    $user = User::factory()->create();
    $user->deviceTokens()->createMany([['token' => str_repeat('a', 64)], ['token' => str_repeat('b', 64)]]);
    $order = Order::factory()->for($user)->create();

    $user->notify(new OrderShipped($order));

    Queue::assertPushed(SendPushNotification::class, 2);
    Queue::assertPushed(fn (SendPushNotification $job) => $job->deviceToken === str_repeat('b', 64)
        && $job->data === ['link' => "/orders/{$order->number}"]);
}

public function test_a_user_without_devices_gets_nothing(): void
{
    Queue::fake();
    $order = Order::factory()->create();

    User::factory()->create()->notify(new OrderShipped($order));

    Queue::assertNothingPushed();
}
Enter fullscreen mode Exit fullscreen mode

For the wire format, fake HTTP and run the job. The JWT has to be signed with something, so generate a throwaway P-256 key in setUp() instead of pointing tests at your real .p8:

protected function setUp(): void
{
    parent::setUp();

    $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']);
    openssl_pkey_export($key, $pem);
    file_put_contents($path = tempnam(sys_get_temp_dir(), 'apns'), $pem);

    config([
        'services.apns.key_path' => $path,
        'services.apns.key_id' => 'TESTKEY123',
        'services.apns.team_id' => 'TEAM123456',
        'services.apns.bundle_id' => 'com.acme.app',
    ]);
}

public function test_the_request_matches_what_apns_expects(): void
{
    Http::fake(['api.sandbox.push.apple.com/*' => Http::response(status: 200)]);

    (new SendPushNotification(str_repeat('a', 64), 'Order #1234 shipped', 'Arriving tomorrow.', ['link' => '/orders/1234']))
        ->handle(app(ApnsClient::class));

    Http::assertSent(fn (Request $request) => $request->url() === 'https://api.sandbox.push.apple.com/3/device/'.str_repeat('a', 64)
        && $request->header('apns-topic') === ['com.acme.app']
        && $request['aps']['alert']['title'] === 'Order #1234 shipped'
        && ($request['link'] ?? null) === '/orders/1234');
}

public function test_an_unregistered_token_is_removed(): void
{
    Http::fake(['api.sandbox.push.apple.com/*' => Http::response(['reason' => 'Unregistered'], 410)]);

    $user = User::factory()->create();
    $user->deviceTokens()->create(['token' => str_repeat('c', 64)]);

    (new SendPushNotification(str_repeat('c', 64), 'Hi', 'There'))->handle(app(ApnsClient::class));

    $this->assertDatabaseMissing(DeviceToken::class, ['token' => str_repeat('c', 64)]);
}

public function test_each_token_goes_to_its_own_apns_host(): void
{
    Http::fake();
    config(['services.apns.production' => true]);

    $user = User::factory()->create();
    $user->deviceTokens()->createMany([
        ['token' => str_repeat('a', 64), 'environment' => 'sandbox'],   // dev build
        ['token' => str_repeat('b', 64), 'environment' => 'production'], // App Store build
    ]);

    (new SendPushNotification(str_repeat('a', 64), 'Hi', 'There'))->handle(app(ApnsClient::class));
    (new SendPushNotification(str_repeat('b', 64), 'Hi', 'There'))->handle(app(ApnsClient::class));

    Http::assertSent(fn (Request $request) => $request->url() === 'https://api.sandbox.push.apple.com/3/device/'.str_repeat('a', 64));
    Http::assertSent(fn (Request $request) => $request->url() === 'https://api.push.apple.com/3/device/'.str_repeat('b', 64));
}
Enter fullscreen mode Exit fullscreen mode

These earn their keep. I broke the code on purpose to check: sending to the first device only fails the fan-out test, moving link inside aps (an easy slip when you build the payload) fails the wire-format test, and going back to the single APNS_PRODUCTION switch fails the host test. One more for the endpoint, signing two users in on the same phone with Sanctum::actingAs(), is what caught the firstOrCreate version returning 500.

Wrapping up

Part 1 was about talking to Apple. This part is about not having to think about it: a table for devices that remembers each token's environment, an endpoint that treats the token as the device's identity, a channel that fans out to one job per device, and $user->notify() everywhere else. Sending an iOS push now looks exactly like sending an email.

If you're building with NativePHP Mobile (v3 or v4), the app side of this is the plugin I sell, Push Direct: it registers with APNs directly on iOS (FCM on Android), hands you the raw token to post to the endpoint above along with its environment (Push::environment()), and delivers the payload, the tap and the cold start to your Livewire components, so that link opens the right screen even when the app was closed. It also handles action buttons, notification images and silent pushes. Full disclosure, it's paid. Everything in this post works without it.

Top comments (0)