laravel-notification-channels/fcm
A Laravel notification channel for Firebase Cloud Messaging (FCM). Send push notifications to Android, iOS, and web devices through Laravel's notification system, with support for topics, data payloads, and the FCM HTTP v1 API.
Install and publish the config:
composer require laravel-notification-channels/fcm
php artisan vendor:publish --tag=fcm-config
v6 requires PHP 8.4+ and Laravel 12+. If you're still on Laravel 11 or PHP 8.3 you cannot take this release — pin ^5.0 and accept the older API surface (see the version-locking note under Gotchas before you let Composer drift).
The package talks to the FCM HTTP v1 API, so you need a service account rather than the old server key. In the Firebase console: Project settings → Service accounts → Generate new private key, drop the JSON somewhere outside public/ (e.g. storage/app/firebase/service-account.json), then:
FCM_CREDENTIALS=/var/www/app/storage/app/firebase/service-account.json
The project id normally comes from that file; the config exposes an override if you run multiple Firebase projects. Run php artisan config:clear after touching .env — a cached config with a stale path is the single most common "it worked locally" failure.
Give your notifiable a route. The channel calls routeNotificationFor('fcm'), which resolves to routeNotificationForFcm():
// app/Models/User.php
public function routeNotificationForFcm(): string|array|null
{
return $this->fcm_token; // or $this->devices()->pluck('token')->all()
}
Your first notification:
use NotificationChannels\Fcm\FcmChannel;
use NotificationChannels\Fcm\FcmMessage;
use NotificationChannels\Fcm\Resources\Notification as FcmNotification;
class OrderShipped extends Notification
{
public function __construct(public Order $order) {}
public function via($notifiable): array
{
return [FcmChannel::class];
}
public function toFcm($notifiable): FcmMessage
{
return FcmMessage::create()
->setNotification(
FcmNotification::create()
->setTitle('Order shipped')
->setBody("Order #{$this->order->id} is on its way")
)
->setData([
'order_id' => (string) $this->order->id,
'type' => 'order_shipped',
]);
}
}
First places to read: config/fcm.php, then src/FcmChannel.php (what actually gets sent), src/FcmTopicChannel.php (the new topic-send path), src/FcmMessage.php (the top-level payload), and src/Resources/ — every platform-specific knob (AndroidConfig, ApnsConfig, WebpushConfig, FcmOptions) is a typed value object there. The README's per-platform snippets are the fastest reference; ignore blog posts older than v2, they use the decommissioned legacy API.
Fan-out to multiple devices. Return an array from routeNotificationForFcm(); the channel loops and issues one v1 request per token. Store tokens in a devices table keyed by user + platform so you can prune per-device rather than guessing.
Topics via the new FcmTopicChannel. v6 ships a dedicated channel for topic sends, which is the cleanest way to do "everyone subscribed to promotions" — no server-side token bookkeeping, no fan-out. Register it in via() alongside (or instead of) FcmChannel and give the model its topic route; check src/FcmTopicChannel.php for the exact route method name in your installed version before wiring your model.
public function via($notifiable): array
{
return [FcmTopicChannel::class];
}
You can still target a topic from a plain FcmMessage with setTopic('promotions'). Reach for the raw message when you need condition targeting rather than a single topic:
return FcmMessage::create()
->setCondition("'promotions' in topics || 'vip' in topics")
->setNotification(FcmNotification::create()->setTitle('Flash sale')->setBody('30% off today'));
setToken(), setTopic(), and setCondition() are mutually exclusive — pick one targeting mode per message.
Data-only (silent) messages. Omit setNotification() entirely and let the app decide what to render. Android needs to be woken up:
FcmMessage::create()
->setData(['type' => 'sync', 'payload' => json_encode($this->changes)])
->setAndroid(AndroidConfig::create()->setPriority('high'))
->setApns(ApnsConfig::create()
->setHeaders(['apns-priority' => '5', 'apns-push-type' => 'background'])
->setPayload(ApnsPayload::create()->setAps(Aps::create()->setContentAvailable(true))));
Platform helpers. v6 adds android() and ios() shortcuts on the message class for the common case of attaching platform-specific notification data:
FcmMessage::create()
->setNotification(FcmNotification::create()->setTitle('Order shipped')->setBody('...'))
->android(channelId: 'orders', clickAction: 'OPEN_ORDER')
->ios(sound: 'default', badge: $unread);
Treat these as the 80% convenience path. Drop down to the full setAndroid(AndroidConfig::create()->...) / setApns(ApnsConfig::create()->...) objects the moment you need headers, APNs priority, content-available, or FcmOptions — the helpers don't replace them, and mixing both on one message is a good way to silently lose a setting.
Platform polish (full config).
->setAndroid(AndroidConfig::create()->setNotification(
AndroidNotification::create()
->setChannelId('orders')
->setClickAction('OPEN_ORDER')
->setImage($this->order->imageUrl)
))
->setApns(ApnsConfig::create()
->setHeaders(['apns-priority' => '10'])
->setPayload(ApnsPayload::create()->setAps(Aps::create()->setSound('default')->setBadge($unread))))
->setFcmOptions(FcmOptions::create()->setAnalyticsLabel('order_shipped'));
On-demand notifications (no user model, e.g. after a guest checkout or from a webhook) skip the notifiable entirely:
Notification::route('fcm', $deviceToken)->notify(new OrderShipped($order));
Shared base notification. Most apps end up with 90% identical payloads. Extract an abstract FcmNotification base with a protected function basePayload(): FcmMessage that sets channel id, click action, analytics label, and a common data.type, then let subclasses override title/body/data. Keeps deep-link routing consistent across the app.
Testing. Notification::fake() covers "was it sent"; add a payload assertion so schema regressions don't reach production:
Notification::fake();
$user->notify(new OrderShipped($order));
Notification::assertSentTo($user, OrderShipped::class, function ($notification, $channels) use ($user) {
$payload = $notification->toFcm($user)->toArray();
return in_array(FcmChannel::class, $channels)
&& $payload['data']['type'] === 'order_shipped';
});
Queue it. Implement ShouldQueue on noisy notifications and give the queue worker retries/backoff; a fan-out to 5k tokens inside a web request will time out.
composer update will resolve back to v5 — which is fine, but it means your codebase may be running v5 APIs while the README documents v6 (FcmTopicChannel, android()/ios() helpers won't exist). Check composer show laravel-notification-channels/fcm before copy-pasting from the docs.composer.json. Each of v1→v2 (value objects, legacy API removal) and v5→v6 (Laravel 12 floor) changes what you can call, so an unpinned upgrade is a rewrite or a silent downgrade, never a patch. Use "^6.0" or "^5.0" deliberately.FCM_SERVER_KEY no longer exists; every send now needs the service-account JSON. A 401 with a valid-looking key almost always means you're still on the old config.NotificationChannels\Fcm\Resources\Notification vs Illuminate\Notifications\Notification. Always alias it (as FcmNotification) or you'll get fatal errors in your first file.data values must be strings. FCM rejects (or silently drops) ints, bools, and nested arrays in the data payload. Cast: (string) $id, $flag ? '1' : '0', and json_encode() any structure. This bites hardest with Eloquent models — never pass $order->toArray().notification vs data changes background behavior. With a notification block, the OS renders the tray entry and onMessageReceived may not fire while backgrounded; without one, the app must wake itself. Decide per notification type, not per app.FcmTopicChannel / setTopic() returns a message id, not per-token results — you cannot detect unregistered tokens this way, and a user who never subscribed on-device gets nothing with zero errors. If you need delivery observability, fan out to tokens instead, or subscribe clients deliberately and verify server-side via the Topic Management API.routeNotificationForFcm() returns null/[], the channel returns early without throwing. Users with a missing fcm_token just never get notified. Log or alert on that path in routeNotificationForFcm().NotificationChannels\Fcm\Exceptions\CouldNotSendNotification, inspect the response for UNREGISTERED / INVALID_ARGUMENT and delete the token; SENDER_ID_MISMATCH means the token belongs to a different Firebase project (staging vs prod credentials swapped).apns-push-type: background + content-available: 1 + priority 5 (not 10). Priority 10 background pushes get throttled or dropped by APNs. The ios() helper won't set these for you.setChannelId('orders')) with a matching channel created client-side; without it the notification vanishes with no error server-side.dd($notification->toFcm($user)) is faster than guessing why nothing renders. The response body only tells you about API-level rejection, not "the OS decided not to display it".FcmChannel (bind it in a service provider or reference it from via()) to add logging, metrics, or automatic invalid-token cleanup in one place; tune HTTP timeouts/client options through the config, and use setFcmOptions() for analytics labels rather than shoehorning tags into data.How can I help you explore Laravel packages today?