Looking to hire Laravel developers? Try LaraJobs

laravel-fcm-push maintained by npav

Description
Firebase Cloud Messaging (HTTP v1) web push for Laravel — hand-signed OAuth2 JWT (no google/apiclient or kreait/firebase-php dependency), channel/audience-based subscriber targeting, and on-device diagnostics logging for mobile debugging.
Author
NPAV
Last update
2026/08/13 09:03 (dev-master)
License
Downloads
8

Comments
comments powered by Disqus

npav/laravel-fcm-push

Firebase Cloud Messaging (HTTP v1) web push for Laravel — no kreait/firebase-php or google/apiclient dependency (the OAuth2 JWT is hand-signed with openssl, which matters if you're on a PHP build without the sodium extension, where those packages won't install at all). Channel/audience-based subscriber targeting, plus on-device diagnostics logging, because mobile browsers have no console you can open when a push silently doesn't show up.

New to this package? docs/GETTING-STARTED.md is a step-by-step walkthrough (Firebase setup → install → wire it up → debug a "notification didn't show" report) written for someone doing this for the first time. This README is the API reference.

Install

composer require npav/laravel-fcm-push
php artisan vendor:publish --tag=fcm-push-config
php artisan migrate

Can't run php artisan migrate against the target database (a remote/shared host where schema changes go through a DBA or manual execution)? Run database/sql/create_fcm_push_subscriptions_table.sql by hand instead — it's the exact same table.

Add to .env:

FCM_CREDENTIALS=storage/app/firebase/your-service-account.json
FCM_PROJECT_ID=your-project-id

# Only needed if you use the published sw.js stub (see "Service worker" below)
FCM_API_KEY=
FCM_AUTH_DOMAIN=
FCM_STORAGE_BUCKET=
FCM_MESSAGING_SENDER_ID=
FCM_APP_ID=
FCM_VAPID_KEY=

Download the service account key from Firebase Console → Project Settings → Service Accounts → Generate new private key. Keep it out of version control.

CSRF — one required step

The service worker calls POST /fcm-push/log via a bare fetch(), which has no access to your page's CSRF meta tag. That route must be excluded from CSRF verification, or every diagnostic beacon 302-redirects instead of logging anything (harmless, but you lose the diagnostics).

Laravel 11+ (bootstrap/app.php):

->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'fcm-push/log',
    ]);
})

Laravel 10 (app/Http/Middleware/VerifyCsrfToken.php):

protected $except = [
    'fcm-push/log',
];

Server-side usage

use Npav\FcmPush\Facades\FcmPush;

// Subscribe a device (typically called from your own controller, after your
// own validation that $channel refers to something real):
FcmPush::subscribe($channel, $fcmToken, $audience = null);

// Push to everyone subscribed to a channel:
FcmPush::sendToChannel(
    channel: $channel,
    title: 'New message',
    body: $commentText,
    data: ['tag' => "chat-{$ticketId}-{$commentId}", 'icon' => $iconUrl],
    url: $chatUrl,
);

// Push to only one audience within a channel (e.g. bidirectional chat —
// dealer messages notify 'admin' subscribers, admin messages notify
// everyone else):
FcmPush::sendToChannel($channel, $title, $body, data: [...], url: $url, audience: 'admin');

channel is whatever groups your recipients — a ticket token, an order id, a chat room slug. audience is an optional tag for splitting a channel's subscribers into groups (e.g. 'admin' vs null for "everyone else"). There's no foreign key on channel — the package doesn't know your schema.

If you don't need per-request validation before subscribing, the package ships a ready-to-use endpoint: POST /fcm-push/subscribe with {channel, fcm_token, audience?}, named route fcm-push.subscribe.

Client-side usage

Publish the JS helper once:

php artisan vendor:publish --tag=fcm-push-assets
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
<script src="{{ asset('vendor/fcm-push/fcm-push.js') }}"></script>
<script>
    FcmPush.init({
        channel: '{{ $channel }}',
        audience: null, // or e.g. 'admin'
        subscribeUrl: '{{ route('fcm-push.subscribe') }}',
        logUrl: '{{ route('fcm-push.log') }}',
        swUrl: '{{ route('fcm-push.sw') }}',
        firebaseConfig: {
            apiKey: '{{ config('fcm-push.web.api_key') }}',
            authDomain: '{{ config('fcm-push.web.auth_domain') }}',
            projectId: '{{ config('fcm-push.project_id') }}',
            storageBucket: '{{ config('fcm-push.web.storage_bucket') }}',
            messagingSenderId: '{{ config('fcm-push.web.messaging_sender_id') }}',
            appId: '{{ config('fcm-push.web.app_id') }}',
        },
        vapidKey: '{{ config('fcm-push.web.vapid_key') }}',
        debug: new URLSearchParams(location.search).has('debug'),
        onForegroundMessage: function (data) {
            // data-only payload's `data` object — title/body/url/tag/whatever
            // you sent as `data:` in sendToChannel(). Optional: falls back to
            // a plain Notification() if you omit this.
        },
    });
</script>

Service worker

GET /sw.js is registered automatically at the domain root (a service worker's control scope is limited to its own path and below, so root is usually what you want). It serves resources/stubs/sw.js.stub with your fcm-push.web.* config injected — no hardcoded config in a static file to keep in sync with .env.

If you need the service worker at a different path, or you already have one of your own registering push handlers, don't rely on the package route — call Npav\FcmPush\Http\Controllers\FcmServiceWorkerController::serve() from wherever suits you, or copy resources/stubs/sw.js.stub as a starting point.

Gotchas this package already fixes for you

These cost real debugging time building the thing this package was extracted from — recorded here so you don't rediscover them:

  • Foreground messages need onMessage(), background needs onBackgroundMessage() — they're mutually exclusive. A data-only payload (no top-level notification key — deliberate, see below) that arrives while the tab is focused goes to messaging.onMessage() in the page; otherwise it goes to the service worker's onBackgroundMessage(). Skip either one and pushes silently vanish in that state, with FCM reporting a clean 200 OK the whole time. FcmPush.init()'s onForegroundMessage and the published sw.js stub both handle this — don't remove one thinking the other covers it.

  • onBackgroundMessage's callback must return its promise chain. Firebase wraps your callback's return value in event.waitUntil() to keep the worker alive; if your callback doesn't return the showNotification() chain, the OS is free to suspend the worker before it finishes, especially on mobile. Reads as "push arrived but nothing displayed." The stub does this correctly — if you customize it, keep the return.

  • Why the payload is data-only, not notification. A notification payload lets the browser auto-display in the background, but silently drops with no callback at all in the foreground — there's nothing to hook even if you wanted to. Data-only puts display fully in your control on both ends, which is strictly more debuggable.

  • Web push defaults to normal urgency, which mobile OSes throttle. Doze / App Standby / OEM battery managers (Xiaomi, Samsung, etc. are particularly aggressive) can delay normal-urgency pushes by minutes to hours. This package always sends webpush.headers.Urgency: high (see the urgency config key) — this is usually the fix for "works instantly on desktop, arrives late or never on a phone."

  • A denied Notification permission can never be re-prompted by JS. Once a user (or a previous version of your page, or a stray click) denies it, only changing the browser's site settings can undo that — Notification. requestPermission() will just resolve to 'denied' again forever. FcmPush.init() logs the permission state on every call (page:init, detail: "permission=denied") specifically so this is visible in your logs instead of presenting as an unexplained "it just doesn't work."

  • A 200 OK from FCM means "accepted for delivery," not "displayed." Both facts can produce a happy-looking log line while nothing appears on the recipient's device. Treat sendToChannel()'s sent count as "handed off to FCM," and use the diagnostics beacons (sw:push-received, sw:notification-shown) as the actual proof of delivery.

  • iOS Safari web push requires the site to be added to the home screen (iOS 16.4+) — it does not work in a normal Safari tab, and there's no error to catch; the permission prompt just never fires the way you'd expect.

Debugging a "no notification" report

  1. Have the reporter open the page with ?debug=1 once — the client JS persists this and starts sending diagnostic beacons.
  2. Reproduce, then grep your logs for FCM client event and FCM push.
  3. Read the trail in order: page:init (was permission ever granted?) → page:sw-registeredpage:got-tokenpage:subscribed → (send from the other side) → FCM push sending / FCM push response (what did FCM's API actually say?) → sw:push-received (did the OS deliver it to the device at all?) → sw:notification-shown.
  4. Whichever step is missing tells you where it's actually breaking — almost always earlier in that chain than "FCM."