amdadulhaq / fcm-laravel
A simple, lightweight, fast Firebase Cloud Messaging (FCM) notification channel for Laravel.
Fund package maintenance!
Requires
- php: ^8.2|^8.3|^8.4|^8.5
- google/auth: ^1.53
- illuminate/notifications: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- driftingly/rector-laravel: ^2.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A simple, lightweight, fast Firebase Cloud Messaging (FCM) notification channel for Laravel. No Firebase Admin SDK, no bloat — just push notifications, configurable, with a fake for testing.
Requirements
- PHP 8.2, 8.3, 8.4, or 8.5
- Laravel 12 or 13
Installation
composer require amdadulhaq/fcm-laravel
The service provider is auto-discovered. Publish the config file:
php artisan vendor:publish --tag=fcm-config
Configuration
Set FIREBASE_CREDENTIALS to the path of your Firebase service account JSON file (download it from Project settings → Service accounts in the Firebase console). The Firebase project ID is read from that file automatically — nothing else is required to start sending.
FIREBASE_CREDENTIALS=storage/app/firebase-adminsdk.json
See config/fcm.php for every option:
| Option | Env | Default | Purpose |
|---|---|---|---|
credentials |
FIREBASE_CREDENTIALS |
— | Path to the service account JSON. Absolute or relative to the app base path. |
http.timeout |
FCM_HTTP_TIMEOUT |
10 |
HTTP timeout (seconds) for the send request. |
http.retries |
FCM_HTTP_RETRIES |
2 |
Extra attempts after a 429, 5xx or connection error. 0 disables retries. |
http.retry_delay |
FCM_HTTP_RETRY_DELAY |
500 |
Delay (ms) before the first retry; doubles on each further attempt. A Retry-After header wins over it. |
http.max_retry_delay |
FCM_HTTP_MAX_RETRY_DELAY |
10000 |
Upper bound (ms) for any single retry delay, including Retry-After, so a queue worker is never parked for minutes. |
android.priority |
FCM_ANDROID_PRIORITY |
normal |
Default for every message's android payload. Set to null to omit. A notification can override it for itself alone — see Notifications. |
android.channel_id |
FCM_ANDROID_CHANNEL_ID |
default_channel |
Android notification channel ID. Set to null to omit. A notification can override it for itself alone too. |
apns.priority |
FCM_APNS_PRIORITY |
— | Sets apns.headers.apns-priority (5 or 10). Omitted from the payload unless set. |
debug |
FCM_DEBUG |
APP_DEBUG |
Logs skipped sends, rejected tokens, and delivery failures. Independent of the host app's app.debug. |
log_channel |
FCM_LOG_CHANNEL |
— | Log channel debug messages are written to. Defaults to the app's default channel. |
dry_run |
FCM_DRY_RUN |
false |
Builds and logs the message but never calls the FCM API; send() returns true as if it succeeded. Handy for local/staging. |
Notifications
Implement toFcm() on any notification, have it implement HasFcmPayload, and route it through the fcm channel:
use AmdadulHaq\Fcm\Contracts\HasFcmPayload; use AmdadulHaq\Fcm\FcmMessage; use Illuminate\Notifications\Notification; class OrderShipped extends Notification implements HasFcmPayload { public function via(object $notifiable): array { return ['database', 'fcm']; } public function toFcm(object $notifiable): FcmMessage { return FcmMessage::create('Order shipped', "Your order #{$this->order->id} is on its way!") ->withData(['order_id' => (string) $this->order->id]); } }
FcmMessage is immutable: every with*() call returns a copy. The optional parts override the configured defaults for that one message only:
| Method | Effect |
|---|---|
withData(array $data) |
FCM data map (string values). |
withPriority('high'|'normal') |
Overrides config('fcm.android.priority'). Anything else throws InvalidArgumentException. |
withChannelId(string $id) |
Overrides config('fcm.android.channel_id'). |
withBadge(int $count) |
App icon badge: apns.payload.aps.badge on iOS (0 clears it) and android.notification.notification_count on Android, where the launcher decides whether to show it. Negative counts throw. |
public function toFcm(object $notifiable): FcmMessage { return FcmMessage::create('Urgent: action needed', $this->body()) ->withPriority('high') ->withChannelId('urgent_channel') ->withBadge($notifiable->unreadNotifications()->count()); }
channel_id only changes which Android notification channel the OS delivers into — the app must still create that channel itself (flutter_local_notifications's createNotificationChannel, or NotificationManager.createNotificationChannel on native Android) with whatever importance/sound it wants; this package only stamps the outgoing message with the channel's ID.
The channel resolves device tokens via Laravel's standard routeNotificationFor('fcm', $notification) convention. Add a routeNotificationForFcm() method to the notifiable:
public function routeNotificationForFcm(): array { return $this->deviceTokens()->pluck('token')->all(); }
Sends to a token that FCM reports as unregistered or invalid throw internally and are reported through the AmdadulHaq\Fcm\Events\FcmTokenRejected event instead of stopping delivery to the notifiable's other tokens — listen for it to prune the dead token from wherever you store them. Only token errors count: UNREGISTERED, SENDER_ID_MISMATCH, and an INVALID_ARGUMENT that names message.token. An INVALID_ARGUMENT about the payload (for example a non-string data value) is logged as a failed send and the token is kept:
use AmdadulHaq\Fcm\Events\FcmTokenRejected; class PruneRejectedFcmToken { public function handle(FcmTokenRejected $event): void { DeviceToken::query()->where('token', $event->token)->delete(); } }
Sending directly
For a one-off send outside the notification system, use FcmService directly:
use AmdadulHaq\Fcm\FcmMessage; use AmdadulHaq\Fcm\FcmService; app(FcmService::class)->send( $deviceToken, FcmMessage::create('Order shipped', 'Your order is on its way!')->withData(['order_id' => '123']), );
Testing
Use FcmService::fake() to swap the real service with an in-memory fake and assert on what would have been sent, without dispatching anything or hitting the network:
use AmdadulHaq\Fcm\FcmMessage; use AmdadulHaq\Fcm\FcmService; $fake = FcmService::fake(); // ... code under test that notifies via the "fcm" channel ... $fake->assertSent(fn (string $token, FcmMessage $message) => $message->title === 'Order shipped'); $fake->assertSentTo($deviceToken, fn (FcmMessage $message) => $message->badge === 3); $fake->assertSentCount(1); $fake->assertNothingSent(); $fake->assertNotSent(fn (string $token) => $token === 'some-other-token');
assertSent() / assertNotSent() callbacks receive (string $token, FcmMessage $message); assertSentTo()'s receives just the FcmMessage.
$fake->rejectToken($token) makes sends to that token throw FcmTokenRejectedException (without a real FCM error response), so you can exercise the FcmTokenRejected pruning path in tests.
Running the package's own test suite
composer install composer test # Pest composer analyse # Larastan composer lint:check # Pint
License
MIT. See LICENSE.md.