aporat/laravel-push-notifications
A Laravel package for sending push notifications to mobile devices. It offers a simple API for composing and dispatching notifications, helping you add iOS and Android push delivery to your app without building the underlying integration yourself.
Install the integration and its core:
composer require aporat/laravel-push-notifications
Before writing any code, confirm the install actually resolved aporat/push-notifications. The integration's own composer.json points the core at a sibling checkout via a path repository, and Composer ignores path repositories declared in dependencies — so if the core isn't on Packagist yet, composer require will fail to resolve it. In that case add your own repository entry to your root composer.json:
"repositories": [{ "type": "path", "url": "../push-notifications" }]
Then look at, roughly in order:
Push::app(). Two drivers: apns (bundle_id, team_id, key_id, private_key) and fcm (service_account). Both credential fields accept a path or the literal PEM/JSON, which is what makes secrets-manager injection work.php artisan push:test — check --help for the app-name/token arguments. Run it first, before touching your notification classes. It separates "credentials are wrong" from "my code is wrong", which is where most of the first hour otherwise goes.app, token, user_id, timestamps, unique on app + token).Minimal first use case — a transactional push to a logged-in user:
// config/push.php (illustrative shape)
'apps' => [
'ios' => [
'driver' => 'apns',
'bundle_id' => env('APNS_BUNDLE_ID'),
'team_id' => env('APNS_TEAM_ID'),
'key_id' => env('APNS_KEY_ID'),
'private_key' => env('APNS_KEY_PATH'), // or the PEM text
],
'android' => [
'driver' => 'fcm',
'service_account' => env('FCM_SERVICE_ACCOUNT_PATH'), // or the JSON
],
],
class OrderShipped extends Notification
{
public function via($notifiable): array
{
return [PushChannel::class];
}
public function toPush($notifiable): PushNotification
{
return PushNotification::create()
->title('Shipped')
->body("Order #{$this->order->id} is on its way");
}
}
// User model
public function routeNotificationForPush($notification): array
{
return $this->deviceTokens()->pluck('token')->all();
}
$user->notify(new OrderShipped($order));
Route per app, not per token. The channel groups targets by app and sends concurrently, so the cleanest design is to keep tokens in a table keyed by app and let routeNotificationForPush() return plain tokens — not a map. If you need per-app targeting (e.g. iOS gets a badge, Android doesn't), use two notification classes or inspect $notifiable inside toPush().
Queue the notification, don't push inline from a controller. Clients live for the life of the process — that's the whole point of the shared-connection design. A queued notification handled by a long-lived worker reuses the APNs connection and cached provider token across thousands of jobs. A push fired in an HTTP request rebuilds everything per request and adds 200–800ms to your response.
class OrderShipped extends Notification implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300];
}
Clear dead tokens in exactly one place. Register a single global listener for DeviceTokenUnregistered and delete by (app, token). Because the event carries the app context, one listener covers every app you configure — you never want per-app token-cleaning logic scattered around.
Event::listen(DeviceTokenUnregistered::class, function ($event) {
DeviceToken::where('app', $event->app)
->where('token', $event->token)
->delete();
});
Treat NotificationFailed as an ops signal, not a user-facing error. Refused targets raise Laravel's NotificationFailed; log it with the app name and the rejection reason so you can spot a rotated key or a bundle-ID mismatch without reading APNs docs.
Fan out to real sends when you need to target an app directly. For broadcasts, admin tools, or sends outside the notification system, resolve the sender and use the core package's message API:
Push::app('ios')->send($message, $tokens);
Keep that path rare — going through notifications gets you the queue, retries, channel grouping, and Push::fake() for free.
Use Push::fake() in feature tests, and assert on the notification, not the payload.
Push::fake();
$user->notify(new OrderShipped($order));
Push::assertSentTo($user, OrderShipped::class);
Push::assertSentCount(1);
Push::assertNothingSent(); // for the negative case
Sandbox fallback is a debugging aid, not a production feature. Enable it per app on your staging config so Xcode-build tokens (which are sandbox tokens) work against a production-configured app. It only covers BadDeviceToken — it will not rescue an expired key or a wrong team ID, so don't let it mask a credential problem in local development.
Centralize token registration too. A POST /devices endpoint that upserts on (app, token) and re-points the row to the current user handles the "same device, different login" case that otherwise produces pushes to the wrong person.
The path-repository trap is the first failure you'll hit. A fresh composer require on a project where the core isn't published yet resolves nothing, and the error message points at the integration, not the missing core. Check composer show aporat/push-notifications before debugging anything else.
php artisan config:cache will bake your PEM and service-account JSON into bootstrap/cache/config.php. If your deploy flow puts secrets in via a secrets manager at boot, cache the config after injection, and make sure the file isn't readable by anything that shouldn't have your APNs signing key. Preferring private_key/service_account as a path on disk avoids the config cache entirely — the file is read at client construction.
Rotating credentials requires more than a config change. Clients are cached for the process lifetime, and provider/access tokens sit in the Laravel cache store. After a key rotation: re-run config:cache, php artisan queue:restart, and flush the token cache keys, or workers will keep presenting the old key until they cycle.
Use a genuinely shared cache store for tokens. The token cache goes through "any Laravel cache store", but array and single-container file stores defeat the purpose — every worker fetches its own OAuth token, and FCM will rate-limit you. Redis, Memcached, or the database store; and confirm the TTL is comfortably shorter than the provider's stated expiry so you refresh before a 401 rather than after.
Retry semantics can double-send. A send that reached nobody with a retryable failure throws so the queued notification retries — which is correct, but it means a partially-successful fan-out can re-deliver to the devices that already got the push. Make the notification body idempotent where it matters (include an order/event ID, use a collapse key), or narrow the fan-out so retries are cheap.
Key your token table on (app, token), not token alone. A device can hold both an APNs and an FCM token; if DeviceTokenUnregistered fires for the FCM token and your delete is keyed on the token string only, you've silently killed a working APNs registration. Also guard against a delete racing a re-registration — where('updated_at', '<', $event->occurredAt) or a soft delete + upsert pattern is worth the extra column.
Push::fake() swaps the sender, so events don't fire. Any test asserting that dead tokens get cleaned up must not use the fake — either test the listener directly against a hand-built event, or do an integration test against a stub transport that returns a 410/UNREGISTERED.
APNs connection sharing is per key, not per app. Multiple apps sharing one key_id share one connection (good, cheap). Apps with distinct keys each hold their own connection — relevant if you run workers with many apps configured and a low memory limit; watch RSS after a few thousand jobs.
BadDeviceToken versus Unregistered are different failures. Unregistered means stop sending to that token (this is what fires DeviceTokenUnregistered). BadDeviceToken usually means environment mismatch — production token, sandbox endpoint — which is exactly what the sandbox fallback probes for. Don't blanket-enable the fallback in production; a genuinely malformed token will retry against sandbox and fail twice as slowly.
Run push:test from inside the queue container, not your laptop. Credential paths, cache store, and outbound network rules differ between the two, and a passing local test tells you nothing about whether the worker can reach api.push.apple.com on 443 over HTTP/2.
How can I help you explore Laravel packages today?