Rich Push Plugin for NativePHP Mobile#
Push notifications with a picture in them on iOS and Android, a GIF or video on iOS, plus buttons and replies on iOS, for your NativePHP app.
See it in action#
Overview#
- iOS: a real Notification Service Extension, added to your Xcode project on every build. You don't write any Swift, and you register nothing extra with Apple: no App Group, no new capability.
- Send from Laravel. Use
RichPush::send(), a notification channel, orphp artisan rich-push:sendstraight through Apple. If you already send through Firebase or another service,RichPushMessagebuilds the payload for you instead. - It never loses a push. If the media is too big, too slow or not there, the notification still arrives as plain text.
- Pushes show while your app is open, and taps reach your code. iOS normally hides a push that arrives while the app is in front. This plugin shows it, and tells your app which notification was tapped, including the one that opened it.
- Buttons and replies. "Remind me later", "Mark done", or a reply typed straight into the notification, with nothing lost when the app is closed.
Requirements: NativePHP Mobile 4.x, Laravel 11–13, PHP 8.3+, iOS 18+, Android 10+. For iOS, an Apple Developer account and the Push Notifications capability on your app ID (NativePHP's own push setup already needs this).
Installation#
composer require firsthand/nativephp-rich-push
Don't forget to register the plugin:
php artisan native:plugin:register firsthand/nativephp-rich-push
Make sure push is switched on in config/nativephp.php. Without it, NativePHP leaves out Apple's aps-environment entitlement and the app is never given a device token. The build prints a warning if it's off.
'permissions' => [ 'push_notifications' => true,],
Then rebuild your app. Every iOS build now includes the extension: a second small program inside your app, YourApp.app/PlugIns/NotificationService.appex, with the bundle ID your.app.id.NotificationService. The first signed build registers that ID for you.
To send straight through Apple, add your APNs key to .env. Create one at developer.apple.com → Keys and tick Apple Push Notifications service:
RICH_PUSH_APNS_KEY_ID=ABC123DEFGRICH_PUSH_APNS_TEAM_ID=XYZ987TEAMRICH_PUSH_APNS_KEY_PATH=/secure/path/AuthKey_ABC123DEFG.p8# RICH_PUSH_APNS_TOPIC defaults to NATIVEPHP_APP_ID# RICH_PUSH_APNS_ENVIRONMENT=production (TestFlight + App Store; "sandbox" for Xcode runs)
Usage#
PHP (Super Native / Livewire / Blade)#
In the app, ask for permission, then read the device token and send it to your server:
use Firsthand\RichPush\Facades\RichPush; RichPush::enroll(); // the system "Allow notifications?" promptRichPush::token(); // the APNs token (iOS), once Apple hands it overRichPush::status(); // ['platform', 'permission', 'extension', 'token']
On a native screen:
use Firsthand\RichPush\Events\NotificationTapped;use Firsthand\RichPush\Facades\RichPush;use Native\Mobile\Attributes\On;use Native\Mobile\Edge\NativeComponent; class Notifications extends NativeComponent{ public function allow(): void { RichPush::enroll(); // <native:pressable text="Allow" @press="allow" /> } #[On(NotificationTapped::class)] public function opened(string $action, array $data): void { // a tap, or a button press, while the app is running } public function render() { return view('native.notifications', ['status' => RichPush::status()]); }}
On your server, send a picture push:
$result = RichPush::send($token, RichPush::message('New recipe') ->subtitle('From Aunt Carole') ->body('Lemon bars, the card from 1974') ->image('https://example.com/lemon-bars.jpg') // or ->video(...), ->media(...) for GIFs ->badge(1) ->thread('family-recipes') ->data(['recipe_id' => 42])); if ($result->tokenIsDead()) { $user->update(['apns_token' => null]);} elseif (! $result->ok()) { logger()->warning($result->explanation());}
Or from the command line:
php artisan rich-push:send <device-token> --image=https://example.com/roast.jpg
As a Laravel notification:
use Firsthand\RichPush\Notifications\RichPushChannel;use Firsthand\RichPush\RichPushMessage; class DinnerIsReady extends Notification{ public function via($notifiable) { return [RichPushChannel::class]; } public function toRichPush($notifiable): RichPushMessage { return RichPushMessage::create('Dinner is ready')->image($this->recipe->photo_url); }} // On the notifiable: one token or severalpublic function routeNotificationForRichPush(): array|string { return $this->apns_tokens; } // Optional: called for each token Apple says is gonepublic function richPushTokenDied(string $token, ApnsResult $result): void { /* forget it */ }
Through Firebase or another service, leave the APNs settings empty and use only the payload builder:
$message = RichPushMessage::create('Dinner is ready')->image($url); $message->toFcm($fcmToken); // FCM HTTP v1 "message": image for Android + iOS, mutable-content set$message->toApns(); // the raw APNs JSON body, for any other sender
On iOS, the one thing that matters is mutable-content: 1 together with a media URL. Both builders set it for you. The extension looks for the URL under media, image, image_url, video, attachment-url, fcm_options.image and data.image, so most existing senders work unchanged.
JavaScript (Vue / React / Inertia)#
import { enroll, token, status, takeTap, takeTaps, onNotificationTapped } from '@firsthand/nativephp-rich-push'; await enroll();const t = await token(); // send it to your server onNotificationTapped(({ id, data }) => router.visit(`/recipes/${data.recipe_id}`));const tap = await takeTap(); // the tap that opened the app, or nullconst presses = await takeTaps(); // every tap and button press waiting, oldest first
TypeScript types are included.
Buttons and replies#
Name a set of buttons in config/rich-push.php:
'categories' => [ 'recipe' => [ ['id' => 'cook', 'title' => 'Start cooking', 'foreground' => true], // opens the app ['id' => 'later', 'title' => 'Remind me later'], // works without opening it ['id' => 'reply', 'title' => 'Reply', 'input' => true, 'placeholder' => 'Say something…'], ['id' => 'skip', 'title' => 'Skip', 'destructive' => true], // shown in red ],],
Then pick that set when you send:
RichPush::message('Dinner is ready')->image($photo)->category('recipe')->data(['recipe_id' => 42]);
People press and hold (or swipe) the notification to see the buttons. iOS shows up to four. The build checks this config and stops with a clear message if something's wrong, such as a typo in a key or the same id twice.
Buttons can be pressed without opening the app, and Laravel isn't running then. So the plugin keeps every press, and your app collects them in order the next time it opens:
foreach (RichPush::takeTaps() as $tap) { // oldest first; reading clears them match ($tap['action']) { 'later' => Reminder::snooze($tap['data']['recipe_id']), 'reply' => Comment::create(['recipe_id' => $tap['data']['recipe_id'], 'body' => $tap['text']]), default => null, };}
To act straight away, even with the app closed, set RICH_PUSH_ACTION_URL. Each press is then also POSTed to that URL as JSON (id, action, text, data, at) the moment it happens. Anyone could post to that URL, so put a signed, single-use token in ->data() when you send and check it when the press arrives.
While the app is open#
iOS shows nothing for a push that arrives while your app is in front, unless the app says otherwise. This plugin says "show it". To change that, set RICH_PUSH_FOREGROUND in .env (or foreground in config/rich-push.php):
| Value | A push that arrives while the app is open… |
|---|---|
banner (default) |
shows as usual: banner, sound, and in Notification Center |
list |
goes quietly into Notification Center, with no banner or sound |
none |
is not shown, iOS's own default |
The setting is built into the app, so a change takes effect on the next build.
Events#
NotificationTapped#
Firsthand\RichPush\Events\NotificationTapped, fired on iOS when a notification is tapped or one of its buttons is pressed while the app is running.
| Property | Type | Description |
|---|---|---|
id |
string |
The notification's id, the same one takeTap()/takeTaps() return, so a tap is never handled twice |
action |
string |
open for a normal tap, otherwise the button's id |
data |
array |
Everything you put in ->data() |
text |
?string |
The words typed into a reply button |
at |
?string |
When it happened (ISO 8601) |
use Firsthand\RichPush\Events\NotificationTapped;use Native\Mobile\Attributes\On; #[On(NotificationTapped::class)] // #[OnNative(...)] in a Livewire web-view pagepublic function opened(string $id, string $action, array $data): void{ $this->redirect(route('recipes.show', $data['recipe_id']));}
onNotificationTapped(({ id, action, data, text }) => { /* … */ });
A tap that starts a closed app happens before Laravel is running, so no event can reach your code. Read it with takeTap() on your first screen instead.
Methods#
enroll(bool $provisional = false): bool#
Shows the system "Allow notifications?" prompt. With provisional: true, iOS delivers quietly without asking.
token(): ?string#
The APNs device token (iOS). Apple hands it over a moment after enroll(); if this returns null, ask again shortly afterwards. The plugin keeps the token, so it's there on every later launch.
status(): ?array#
['platform', 'permission', 'extension', 'token']. extension tells you whether this build actually contains the picture extension, a quick check that the build hook ran.
takeTap(): ?array#
The tap that opened the app (['id', 'action', 'text', 'data', 'at']), or null. Reading clears it. If your app uses buttons, use takeTaps(): takeTap() returns just the latest one and clears the rest.
takeTaps(): array#
Every tap and button press waiting, oldest first. Reading clears them.
send(string $token, RichPushMessage $message): ApnsResult#
Sends through Apple. ApnsResult has ok(), tokenIsDead() and explanation().
message(string $title): RichPushMessage#
Builds a push: subtitle(), body(), image(), video(), media(), badge(), sound(), thread(), category(), data(), then toApns() / toFcm($token) if you send it yourself.
| Media | Limit (Apple's) | Formats |
|---|---|---|
| Image / GIF | 10 MB | JPEG, PNG, GIF |
| Video | 50 MB | MP4, MPEG, AVI |
| Audio | 5 MB | MP3, M4A, WAV, AIFF |
The extension has about 30 seconds in total. Media must be on https://.
Outside the app (in a browser or in tests), the device calls return null / false / [], so pages still render.
Platform behavior#
- iOS: pictures, buttons, replies, the foreground setting and tap events all work. If something else in your app already handles notifications, the plugin leaves it in charge and passes everything on to it.
- Android:
enroll()asks for the notification permission (Android 13+) andstatus()reports it. Pictures on Android come from the sender:toFcm()sets Firebase's image field. Android shows it as a still picture: a GIF does not move, and video and audio are not shown. Whether a push shows while the app is open is up to the plugin that receives it (Firebase). - Builds from Xcode use the sandbox server; TestFlight and App Store builds use production. A token from one is refused by the other (
BadDeviceToken). - The iOS Simulator never runs the extension (
simctl pushskips it), so test pictures on a real device. - The extension downloads media itself, so no data is shared between your app and the extension.
Testing#
Script the bridge with NativePHP's testing suite:
use Firsthand\RichPush\Events\NotificationTapped;use Native\Mobile\Testing\Native; it('asks for permission, then follows a tap', function () { Native::fakeBridge() ->respondTo('RichPush.Status', ['platform' => 'ios', 'permission' => 'not_determined', 'extension' => true, 'token' => '']) ->respondTo('RichPush.TakeTaps', ['taps' => []]) ->respondTo('RichPush.Enroll', ['requested' => true]); Native::test(Notifications::class) ->tap('Allow') ->emitNative(NotificationTapped::class, ['id' => 'N1', 'action' => 'open', 'data' => ['recipe_id' => 42]]); Native::fakeBridge()->assertCalled('RichPush.Enroll');});
For the server side, swap Apple out with RichPush::useApnsClient($fake).
Troubleshooting#
| What you see | Why / fix |
|---|---|
token() stays null |
push_notifications is off in config/nativephp.php, or the app ID lacks the Push Notifications capability |
| Push arrives without the picture | Media over the limit, not https, or slow to load. status()['extension'] should be true |
BadDeviceToken |
Sandbox/production mismatch (see above) |
TopicDisallowed / InvalidProviderToken |
The APNs key belongs to another team, or the topic isn't your bundle ID |
| Nothing at all while the app is open | RICH_PUSH_FOREGROUND is none, or another part of your app handles notifications and chose not to show it |
| A native screen shows no text | Native screens need NativePHP's UI package, nativephp/mobile-ui |
License#
One purchase covers unlimited apps. See the LICENSE file in the package.