m0d1xd / expo-notification
Send Expo push notifications from Laravel and track delivery, opens and dismissals per device.
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- filament/filament: ^5.0
- laravel/pint: ^1.27
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^12.5
Suggests
- filament/filament: Adds the Push notifications section to Filament panels (ExpoNotificationPlugin, requires ^5.0)
Provides
None
Conflicts
- filament/filament: <5.0
Replaces
None
This package is auto-updated.
Last update: 2026-10-29 22:35:50 UTC
README
Send Expo push notifications from Laravel and track every notification per device: sent, delivered and opened. The React Native side is expo-notification-client.
Status: 0.x. Android is verified on real devices; iOS is not tested yet. Laravel 12 and 13 are supported; CI runs on Laravel 13.
Quickstart
Laravel
composer require m0d1xd/expo-notification php artisan expo-notification:install
use M0d1xd\ExpoNotification\Concerns\HasPushTokens; class User extends Authenticatable { use HasPushTokens; }
The device routes are registered under /expo-notification (see Devices). Run the
scheduler so receipts are checked.
Expo app (client setup)
npx expo install expo-notification-client expo-notifications expo-task-manager expo-application expo-device \ expo-constants @react-native-async-storage/async-storage
// src/push.ts, imported first by index.ts import { configure, defineBackgroundTask } from 'expo-notification-client'; configure({ apiUrl: 'https://api.example.com' }); defineBackgroundTask();
// in the root component useEffect(() => { register(); return startTracking({ onOpen: (data) => data.url && router.push(String(data.url)) }); }, []);
Send and read the results
use M0d1xd\ExpoNotification\ExpoMessage; use M0d1xd\ExpoNotification\Facades\PushNotifier; $notification = PushNotifier::send(ExpoMessage::create('Hello', 'From Laravel')->data(['url' => '/details']), $user); $notification->stats(); // targeted, accepted, failed, delivered, opened, ... and rates $notification->deliveries; // per device: ticket, receipt, delivered_at, opened_at
Examples
example/: a Laravel + Filament app with the package's Filament dashboard and demo data.- expo-notification-client/example: the Expo app that registers with it and reports events.
Requirements
- PHP 8.3+
- Laravel 12 or 13
Installation
composer require m0d1xd/expo-notification php artisan expo-notification:install
The install command:
- publishes
config/expo-notification.php(an existing one is kept;--forceoverwrites it); - publishes the migrations once (running it again doesn't copy them under new dates);
- adds
EXPO_ACCESS_TOKENandEXPO_NOTIFICATION_TRACKINGto.envand.env.examplewhen they're missing, and never changes existing values (--without-envskips this); - asks to run the migrations.
For custom table names or the foreign owner mode, answer no to the migrate question, edit
config/expo-notification.php (see Table names), then run php artisan migrate.
Or step by step:
php artisan vendor:publish --tag=expo-notification-config php artisan vendor:publish --tag=expo-notification-migrations php artisan migrate
If "Enhanced Security for Push Notifications" is enabled for your project on expo.dev, set the access token:
EXPO_ACCESS_TOKEN=your-expo-access-token
Sending
use M0d1xd\ExpoNotification\ExpoMessage; use M0d1xd\ExpoNotification\Facades\ExpoNotification; $tickets = ExpoNotification::send( ExpoMessage::create('New message', 'Ali sent you a photo') ->data(['url' => '/chats/12']) ->channelId('default') ->high(), ['ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]', 'ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]'], ); foreach ($tickets as $ticket) { $ticket->token; // the recipient $ticket->isOk(); // accepted by the Expo Push Service $ticket->id; // use it to fetch the receipt $ticket->errorType; // ExpoErrorType when rejected, e.g. DeviceNotRegistered }
- One message is sent per token; duplicate tokens are dropped.
- Requests are chunked at 100 messages (Expo's limit), gzip-compressed above 1 KiB, and retried on 429/5xx.
- A rejected message never throws: it comes back as an error ticket and dispatches
M0d1xd\ExpoNotification\Events\PushFailed. Only a failed HTTP request throwsCouldNotSendNotification.
Receipts
$receipts = ExpoNotification::receipts($tickets->pluck('id')->filter()); // keyed by ticket id $receipts['…']->isOk(); $receipts['…']->errorType?->isDeviceNotRegistered();
Receipts are fetched in chunks of 1000 ids. Ids that Expo is still processing are omitted.
A receipt
okonly means Expo handed the message to FCM/APNs. It does not mean the device received it.
Message options
| Method | Notes |
|---|---|
title(), body() / text(), subtitle() (iOS) |
must not be empty |
data(array|Arrayable|JsonSerializable) |
sent as a JSON object |
badge(int) (iOS) |
≥ 0 |
playSound() (iOS) |
default sound |
priority('default'|'normal'|'high'), default(), normal(), high() |
|
ttl(int) / expiresIn(int), expiresAt(DateTimeInterface|int) |
ttl > 0, expiration in the future |
channelId() (Android), categoryId() |
must not be empty |
mutableContent() (iOS), contentAvailable() (iOS) |
|
richContent(string $imageUrl) |
|
when() / unless() |
conditional building |
Push tokens
use M0d1xd\ExpoNotification\ExpoPushToken; use M0d1xd\ExpoNotification\Casts\AsExpoPushToken; $request->validate(['token' => ['required', ExpoPushToken::rule()]]); protected $casts = ['expo_token' => AsExpoPushToken::class];
Devices
Each device registers its Expo push token through the package routes. device_id is the platform install ID
(Android ID / iOS identifierForVendor). Every request carries the device_id + push_token pair.
| Route | Body | Response |
|---|---|---|
PUT /expo-notification/push-tokens |
device_id, push_token, platform (ios/android), locale?, previous_push_token? |
201 created / 200 refreshed |
POST /expo-notification/push-tokens/detach |
device_id, push_token |
204, call on logout |
DELETE /expo-notification/push-tokens |
device_id, push_token |
204, the device stops receiving pushes |
- One row per device, and a push token belongs to one device. A token that shows up on a new device_id moves there.
- The owner is the user signed in on
routes.guard(set it tosanctumfor token-authenticated apps). Guests can register. - A guest refresh with the same token keeps the owner, so registering before the session loads doesn't log anyone out.
Only
detachclears it. - When the token changes, send the old one as
previous_push_tokento keep the owner. Without it the owner is cleared, so knowing a device_id alone never takes over someone else's notifications. detachandDELETEanswer404unless both values match a stored device.
// config/expo-notification.php 'routes' => [ 'enabled' => true, 'prefix' => 'expo-notification', 'middleware' => ['api', 'throttle:60,1'], 'guard' => null, // the default guard, or e.g. 'sanctum' ],
Add HasPushTokens to the owner model to send to all of a user's devices:
use M0d1xd\ExpoNotification\Concerns\HasPushTokens; class User extends Authenticatable { use HasPushTokens; } ExpoNotification::send(ExpoMessage::create('Hi', 'Welcome back'), $user->expoPushTokens());
The same rules are available without HTTP through M0d1xd\ExpoNotification\PushTokenRegistry
(register(), find(), detach(), deactivate()).
Tracked sending
PushNotifier sends to registered devices and records the result per device:
use M0d1xd\ExpoNotification\Facades\PushNotifier; $notification = PushNotifier::send( ExpoMessage::create('New message', 'Ali sent you a photo')->data(['url' => '/chats/12']), $user, // or a PushToken, a PushToken query, $user->pushTokens(), or an array mixing them ); $notification->deliveries; // one row per device: ticket_id, ticket_status, receipt_status, error_type, ...
- Only active tokens are targeted, once per device.
- The sent copy carries
data.notificationId(the notification's ULID); the SDK owns that key. Your message object is not changed. ticket_statusisok,error(Expo rejected the message) orfailed(the request to Expo failed). Messages go out in requests of 100; a failed request only fails its own deliveries.- Every ticket or receipt error dispatches
M0d1xd\ExpoNotification\Events\DeliveryFailed. DeviceNotRegistereddeactivates the token. The device is reactivated when it registers again.- Sending is synchronous. For large audiences, call
PushNotifier::send()from a queued job.
Receipts
expo-notification:check-receipts fetches receipts for deliveries sent at least receipts.delay minutes ago and
stores receipt_status (ok / error). Receipts Expo never returns are marked expired after
receipts.expire_after hours. The package schedules the command every 15 minutes, which needs the
scheduler running.
'receipts' => [ 'delay' => 15, // minutes 'expire_after' => 24, // hours 'schedule' => '*/15 * * * *', // null to schedule it yourself ],
A receipt
okmeans Expo handed the message to FCM/APNs. It does not mark the delivery as delivered:delivered_atis set when the device reports it.
Tracking payloads
With tracking.enabled (the default), every tracked send can be recorded in the background:
| Platform | What is sent | Why |
|---|---|---|
| Android | A data-only push: title, body, channelId, categoryId and the image move into data.render, priority high |
Only data-only pushes wake the app's background task when it is in the background or killed. The client shows the notification itself |
| iOS | The message plus _contentAvailable: true |
Wakes the app for background processing (throttled by iOS). Not verified on devices yet |
Android users see nothing unless the app uses expo-notification-client (or shows
data.renderitself). Without the client, setEXPO_NOTIFICATION_TRACKING=falseto send normal pushes.
The SDK owns the data.notificationId and data.render keys.
Multi-language notifications
Expo does not translate: it delivers a message's title and body exactly as sent. So the server picks each device's
language and sends that device the matching text. Pass a LocalizedMessage instead of an ExpoMessage:
use M0d1xd\ExpoNotification\LocalizedMessage; // Written out per language PushNotifier::send(LocalizedMessage::make([ 'en' => ExpoMessage::create('New bid', 'Ali bid $500')->data(['url' => '/ads/7']), 'ar' => ExpoMessage::create('مزايدة جديدة', 'علي زايد بـ 500$')->data(['url' => '/ads/7']), ])->fallback('en'), $users); // Or built per language, e.g. from translation files or your own templates; only the languages needed are built PushNotifier::send(LocalizedMessage::using( fn (string $locale) => ExpoMessage::create(__('push.bid.title', [], $locale), __('push.bid.body', ['name' => 'Ali'], $locale)), ), $users);
Each device's language is, in order:
- the owner's
preferredLocale(), when the owner model implements Laravel'sIlluminate\Contracts\Translation\HasLocalePreference(also used by Laravel mail); - the
localethe device registered with (expo-notifications-client sends the phone's language); - the fallback:
->fallback(), elselocales.fallback, elseapp.fallback_locale.
ar-SA and ar_EG match ar when there is no exact match. Guests have no owner, so they get their device's language.
// app/Models/User.php class User extends Authenticatable implements HasLocalePreference { public function preferredLocale(): ?string { return $this->preferred_language; // 'en' / 'ar' } }
// config/expo-notification.php 'locales' => [ 'supported' => ['en', 'ar'], // the languages LocalizedMessage::using() builds 'fallback' => env('EXPO_NOTIFICATION_FALLBACK_LOCALE'), // null = app.fallback_locale ],
It is still one notification: title/body hold the fallback language's text, translations holds every language
that was sent, and each delivery records its locale. In the Filament dashboard, ->locales(['en' => 'English', 'ar' => 'العربية']) adds a tab per language to Send notification.
Upgrading from 0.1.x: run
php artisan expo-notification:installagain (it publishes only the newadd_locale_to_push_notificationsmigration), thenphp artisan migrate.
Device events
The client reports what happened to each notification to POST /expo-notification/push-events:
{
"device_id": "…", "push_token": "ExponentPushToken[…]",
"events": [{ "notification_id": "01…", "type": "received", "source": "task", "app_state": "background", "occurred_at": "2026-10-29T10:00:00Z" }]
}
- Up to 100 events per request. The
device_id+push_tokenpair must match a stored device (404otherwise). - An event is stored only for a notification that was sent to that device; others are counted as
ignored. Each notification, device and type is stored once (duplicates). The answer is200 {"stored", "duplicates", "ignored"}. occurred_atis kept between the send time and now.- Each stored event updates the delivery:
receivedsetsdelivered_at(the earliest wins),openedsetsopened_at,dismissedsetsdismissed_at. Opened and dismissed also filldelivered_at, since the notification arrived. - Every stored event dispatches
M0d1xd\ExpoNotification\Events\NotificationEventRecorded.
$notification->deliveries()->whereNotNull('delivered_at')->count(); // delivered $notification->deliveries()->whereNotNull('opened_at')->count(); // opened $notification->events; // the raw device events
What can be tracked (Android, verified on devices)
| App state | received | opened |
|---|---|---|
| Foreground | yes | yes |
| Background | yes | yes |
| Killed by the system | yes | yes (on launch) |
| Force-stopped by the user | no: Android does not start the app until the user opens it; the receipt is still ok |
— |
Dismissals cannot be detected on Android. iOS has not been tested yet.
Analytics
DeliveryStats counts any set of deliveries: one notification, one device, a date range.
use M0d1xd\ExpoNotification\DeliveryStats; use M0d1xd\ExpoNotification\Models\PushNotification; use M0d1xd\ExpoNotification\Models\PushNotificationDelivery; $stats = $notification->stats(); $stats = DeliveryStats::for(PushNotificationDelivery::query()->where('sent_at', '>=', now()->subDays(7))); $stats = DeliveryStats::for($device->deliveries()->getQuery()); $stats->targeted; // deliveries created $stats->accepted; // ticket ok $stats->failed; // ticket error/failed or receipt error $stats->delivered; // reported by the device $stats->opened; $stats->dismissed; $stats->deliveryRate(); // delivered / targeted, 0–1, null when nothing was targeted $stats->openRate(); // opened / delivered, 0–1, null when nothing was delivered // A list of notifications with their counts in one query: PushNotification::query()->withDeliveryStats()->latest('sent_at')->paginate(); // adds deliveries_count, accepted_count, failed_count, delivered_count, opened_count, dismissed_count; // $notification->stats() then reads them without querying again.
The same definitions are available as delivery scopes: failed(), delivered(), opened(), dismissed().
Delivered never counts a receipt ok: only the device can say it got the notification.
Filament dashboard
With Filament 5, add the plugin to a panel to get a Push notifications section:
- Notifications: every notification with targeted, delivered, opened and failed counts and rates; per device, the Expo ticket and receipt, delivered/opened times and the raw device events;
- Devices: registered devices with their owner (or Guest), with send-test and deactivate actions;
- a Send notification action (to all active devices, chosen users or chosen devices);
- dashboard widgets: the last 7 days' totals and a 14-day chart.
use M0d1xd\ExpoNotification\Filament\ExpoNotificationPlugin; public function panel(Panel $panel): Panel { return $panel // ... ->plugin(ExpoNotificationPlugin::make()); }
Every option is optional:
ExpoNotificationPlugin::make() ->navigationGroup('Notifications Center') // default "Push notifications"; null = no group ->navigationSort(10) // the second item follows the first ->notificationsResource(label: 'Sent', icon: Heroicon::OutlinedMegaphone) ->devicesResource(label: 'Phones', enabled: true) ->widgets([PushStatsOverview::class]) // default both widgets; [] = none ->sending(false) // hide every Send action ->ownerModel(User::class, 'email') // listed under "Chosen users", shown as the owner ->locales(['en' => 'English', 'ar' => 'العربية']) // a title/body tab per language; the first is the fallback ->pollingInterval('30s'); // null = no auto-refresh
ownerModel() defaults to owner.model in the foreign owner mode. In morph mode, without it, the
Chosen users target is hidden. The widgets (M0d1xd\ExpoNotification\Filament\Widgets\*) are ordinary Filament
widgets, so you can also put them on any page yourself.
The pages don't check permissions themselves. Restrict them with policies for
M0d1xd\ExpoNotification\Models\PushNotification and PushToken, as for any Filament resource.
Renaming and translating labels
Every label (group, items, columns, filters, actions, widgets) is a translation string:
php artisan vendor:publish --tag=expo-notification-translations
Edit lang/vendor/expo-notification/en/filament.php, or copy it to another locale (ar/filament.php). A published
file only needs the keys you change.
Taking over the code
To change more than the options allow, copy the section into your app:
php artisan expo-notification:filament-publish # to app/Filament/ExpoNotification, namespace App\Filament\ExpoNotification
Then register App\Filament\ExpoNotification\ExpoNotificationPlugin instead of the package's. It has the same
options. The copy is yours: package updates no longer reach it. Files that already exist are kept unless you pass
--force.
Test command
php artisan expo-notification:send "ExponentPushToken[xxx]" --title="Hello" --body="World" # Through PushNotifier, to registered devices, recording deliveries: php artisan expo-notification:send "ExponentPushToken[xxx]" --track
Database schema
| Table | Purpose |
|---|---|
push_tokens |
One row per device: device_id, push_token, platform, locale, nullable owner |
push_notifications |
Each notification sent (ULID id, also sent to devices as data.notificationId); translations for multi-language sends |
push_notification_deliveries |
Per notification per device: locale, Expo ticket/receipt, delivered_at, opened_at, dismissed_at |
push_notification_events |
Raw device events (received / opened / dismissed), stored once each |
The owner is configurable in config/expo-notification.php:
'owner' => [ 'mode' => 'morph', // owner_type + owner_id, any model; or 'foreign' for a plain FK 'key_type' => 'int', // 'int', 'uuid' or 'ulid' 'model' => App\Models\User::class, // 'foreign' mode only 'column' => 'user_id', // 'foreign' mode only ],
Table names
The migrations read tables (and owner) from the config when php artisan migrate runs, so set them before the
first migrate:
// config/expo-notification.php 'tables' => [ 'push_tokens' => 'app_devices', 'notifications' => 'app_push_messages', 'deliveries' => 'app_push_deliveries', 'events' => 'app_push_events', ],
Foreign keys and the models follow the same names. If the config is cached, run php artisan config:clear first.
Changing a name after migrating does not rename anything: the models would look for a table that doesn't exist,
and a rollback would drop the wrong one. Rename the table in a migration of your own
(Schema::rename('push_tokens', 'app_devices')), then change the config.
Coming from laravel-notification-channels/expo
The sending API follows laravel-notification-channels/expo (MIT). Differences:
- A standalone API (
ExpoNotification::send()), not a Laravel notification channel. datais sent as a JSON object, as the Expo API documents.- Requests are chunked at 100 messages, and receipts at 1000 ids.
- Unknown Expo error codes map to
ExpoErrorType::Unknowninstead of throwing.
Testing
composer test
composer lint
License
MIT