Back to marketplace
59

Push Notifications (Firebase)

Enables your users to receive push notifications from your network.

FCM Push — Developer Guide

Overview

The fcm-push module adds Firebase Cloud Messaging (FCM) push notifications to HumHub. When a user receives a HumHub notification, this module dispatches it as a push message to all registered devices of that user.

It supports two delivery channels simultaneously:

ChannelDriver classTarget
Browser / PWAdrivers/Fcm.phpAny browser or PWA that registered via the Firebase JS SDK
Official HumHub mobile appdrivers/Proxy.phpThe community HumHub app (iOS/Android), relayed via https://push.humhub.com
Branded mobile app (custom FCM)drivers/Fcm.phpA custom-branded app that uses the operator's own Firebase project

Architecture

HumHub Notification System
        │
        ▼
NotificationTargetProvider      (implements MobileTargetProvider, replaces it via DI)
        │
        ▼
MessagingService::processNotification()
        │
        ├──► Fcm driver ──────► Firebase API (kreait/firebase-php SDK)
        │       └─ uses service account JSON + Sender ID
        │
        └──► Proxy driver ────► https://push.humhub.com/api/v1/push
                └─ uses humhubApiKey

Driver selection

DriverService initialises both drivers at boot and exposes two accessor methods:

  • getWebDriver() — returns the Fcm driver if configured; never the Proxy (the Proxy is only for native apps).
  • getMobileAppDriver() — returns Fcm when the request comes from a branded app (DeviceDetectorHelper::isAppWithCustomFcm()), otherwise returns Proxy.

Both drivers can be active at the same time. MessagingService::processMessage() iterates all configured drivers and dispatches to each independently.


Configuration (ConfigureForm)

All settings are stored via $module->settings (HumHub key-value store).

SettingDriverDescription
humhubApiKeyProxyAPI key obtained from the HumHub push service portal
senderIdFcmFirebase Messaging Sender ID (numeric)
firebaseApiKeyFcmFirebase Web API Key
firebaseAppIdFcmFirebase Web App ID
firebaseVapidKeyFcmVAPID key pair from Firebase Web Push Certificates
jsonFcmFull contents of the Firebase Service Account JSON file

ConfigureForm::getJsonAsArray() / getJsonParam() parse the raw JSON on the fly. The json field is validated against the expected Google service account structure on save.


Registered Devices — FcmUser model

Database table: fcmpush_user

ColumnDescription
idPK
user_idHumHub user
tokenFCM registration token (unique, issued by the browser/app)
sender_idIdentifies which Firebase project/driver owns this token
created_at / updated_atTimestamps set in beforeSave

One row = one registered device. A user can have many tokens (multiple browsers, multiple devices). The sender_id column lets Proxy tokens and Fcm tokens coexist for the same user without collision.

Token lifecycle

Created / updated

Native apps (Flutter):

  1. User logs in → Events::onAfterLogin() sets SESSION_VAR_REGISTER_NOTIFICATION in the session.
  2. On the next full page render → Events::onLayoutAddonInit() detects the flag and calls MobileAppHelper::registerNotificationScript(), which sends a JS bridge message {type: 'registerFcmDevice', url: '…'} to the Flutter app.
  3. The app obtains an FCM token from Firebase and POSTs it to /fcm-push/token/update-mobile-app.

Browsers / PWA (humhub.firebase.js):

Automatic token registration on page load is routed through requestNotificationPermission(). Despite its name, this function never prompts the user: it only fetches/refreshes the token silently when Notification.permission === 'granted' and returns early otherwise. Prompting on page load (without a user gesture) is ignored by Firefox and Safari/iOS, pushed into Chrome's "quiet" permission UI, and flagged as excessive notification requests by browser security extensions such as Malwarebytes Browser Guard — users who had blocked notifications were re-prompted on every page load. It is triggered in two ways:

  • PWA service worker callback — core's LayoutHeader::registerServiceWorker() calls the global afterServiceWorkerRegistration(registration) whenever the service worker registers or updates, which delegates to requestNotificationPermission(registration).
  • Proactive check in init() — on every page load, init() checks whether Notification.permission === 'granted' AND no token is cached in localStorage. If so, it calls requestNotificationPermission() via navigator.serviceWorker.ready. This covers users who granted permission in browser settings after their initial login, without requiring a logout/login cycle.

The only code path that calls Notification.requestPermission() is enableNotificationsButtonHandler(), bound to the "Enable notifications" button of the notification settings page and of the banner described below. It runs synchronously from the click so the call carries the user-activation context required by iOS/WebKit.

requestNotificationPermission() itself is protected by guards to prevent double execution — which would cause two different tokens if both callers fire on the same page load (e.g. when a service worker update changes the registration object):

  • _tokenRegistrationPending flag — set synchronously before getToken is called, reset in every .then()/.catch() branch of registerToken(). Whichever caller arrives second while the first is still awaiting its promise returns immediately.
  • getTokenLocalStore() check — if a valid token is already cached in localStorage (from an earlier call that already succeeded), skip silently.
  • Notification.permission check — if permission is not granted, skip silently (never prompt, see above).

Enable-notifications banner (EnableNotificationsBanner): Events::onAfterLogin() sets WebAppHelper::SESSION_VAR_SHOW_ENABLE_NOTIFICATIONS_BANNER. onLayoutAddonInit consumes the flag on the first full page render where no user gate is open (core GateManager::hasOpenGate(), so the legal confirmation / 2FA / password-change pages never show it) and adds the banner widget to the layout addons. The flag is removed when the banner is rendered, so it appears at most once per login session. Visibility is decided client-side by initEnableNotificationsBanner(): the banner is only shown when 'Notification' in window, Notification.permission === 'default' and the user has not clicked "No thanks" within the last 30 days (dismissEnableNotificationsBanner() stores the expiry in localStorage under fcmPushEnableNotificationsBannerDismissedUntil). Nothing is shown when notifications are denied: the user blocked them on purpose. Its "Enable notifications" button is bound to enableNotificationsButtonHandler(); whatever the user answers in the browser prompt, the banner is hidden, and no error toast is shown when the answer is "Block" or the prompt is closed. Not rendered for native app requests (DeviceDetectorHelper::isAppRequest()) or when no web driver is configured.

Notification settings (RegisterDeviceTokenButton): rendered above the user's notification settings form for every browser (not native apps) when a web driver is configured. initNotificationSettings() shows the part matching this browser: the "Enable notifications" button (permission default, or granted without this device's token registered server-side), the steps to allow notifications again for the detected browser (denied, chosen by detectBrowser(); the page cannot prompt anymore and browsers ask to reload after the change, after which the page-load path registers the token silently), or on iOS outside of an installed PWA the hint to add the site to the Home Screen. After the browser prompt the part is re-rendered, so "Block" switches straight to the unblock steps.

Once getToken returns a token, sendTokenToServer() POSTs it to /fcm-push/token/update and caches it in localStorage with a 24-hour expiry. On subsequent page loads within that window isTokenSentToServer() returns true and no AJAX call is made.

Server-side (TokenService::storeTokenForUser()):

  • Token already exists for the same user + sender → updated_at is refreshed.
  • Token exists but belongs to a different user or sender → old record deleted, new one created (handles device hand-offs).

Deleted

  • On logout: Events::onAfterLogout() sets two distinct session flags — WebAppHelper::SESSION_VAR_UNREGISTER_NOTIFICATION ('webAppUnregisterNotification') and MobileAppHelper::SESSION_VAR_UNREGISTER_NOTIFICATION ('mobileAppUnregisterNotification'). On the next page render onLayoutAddonInit consumes each flag independently: the web block injects JS calling humhub.modules.firebase.unregisterNotification(), and the mobile block sends an {type: 'unregisterFcmDevice'} Flutter bridge message. Both ultimately POST to TokenController::actionDelete[MobileApp]() → TokenService::deleteToken().

    ⚠️ The two constants must have different string values. If they share a value the first block removes the session key and the mobile unregister script never fires, leaving stale tokens in the database.

    ℹ️ unregisterNotification() reads the token from localStorage and deletes that one token. Only the token cached in the current browser is removed on logout; tokens registered from other browsers or devices remain active in the database.

  • Auto-cleanup of permanently rejected tokens: After every multicast send, MessagingService::processMessage() inspects SendReport::$failedTokens. Only tokens that Firebase has permanently invalidated (unknownTokens = app uninstalled / invalidTokens = malformed) are deleted via TokenService::deleteToken(). Tokens that failed due to transient errors (rate limit, server hiccup) or because a device was simply offline are never deleted — Firebase queues messages for offline devices and does not report them as failures.

  • Manually by an admin: The debug page (/fcm-push/admin/debug) lists all tokens for the currently logged-in admin and provides a delete link.

Notification dispatch flow

  1. A HumHub notification is triggered anywhere in the application.
  2. The core notification module iterates MobileTargetProvider implementations.
  3. Because onBeforeRequest replaced MobileTargetProvider::class with NotificationTargetProvider::class in the DI container, NotificationTargetProvider::handle() is called.
  4. handle() sets the locale to the target user's, then delegates to MessagingService::processNotification().
  5. MessagingService builds the message (title = site name, body = notification text, url = notification entry URL, icon = site icon at 180 px, badge count = unseen notifications count) and calls processMessage().
  6. For each configured driver, TokenService::getTokensForUser() fetches all tokens scoped to that driver's sender_id. If tokens exist, driver->processCloudMessage() is called.

Fcm driver

Uses the kreait/firebase-php SDK (loaded via a dedicated vendor/autoload.php inside the module). Authenticates with the service account JSON. Sends a multicast message with withWebPushConfig (link) and withData (url + notification_count). The imageUrl is intentionally omitted from the notification payload to avoid displaying a duplicate of the logo on branded apps.

After each multicast, the driver inspects the MulticastSendReport for permanently invalid tokens only:

  • unknownTokens() — Firebase returned UNREGISTERED (app was uninstalled, token expired). Safe to delete.
  • invalidTokens() — token is structurally malformed. Safe to delete.

Transient failures (rate limit, server unavailable) appear in failures() but not in the two lists above and are not deleted. A device being off never generates a Firebase failure at all — Firebase queues the message and delivers it when the device reconnects — so offline devices are never at risk of having their token removed.

Proxy driver

Makes an authenticated HTTP POST to https://push.humhub.com/api/v1/push using the humhubApiKey as a Bearer token. The HumHub service relays the message to FCM on behalf of the operator.


Service Worker integration (PWA)

Events::onServiceWorkerControllerInit() hooks into the web/pwa module's service worker generation. If the Fcm driver is configured, it appends importScripts calls for the Firebase compat SDK and the firebase.initializeApp() + firebase.messaging() bootstrap to the service worker JS. This is required for background message reception in PWA mode.


Event hooks summary

EventHandlerPurpose
Application::EVENT_BEFORE_REQUESTonBeforeRequestRegisters NotificationTargetProvider in the DI container when a driver is configured
ServiceWorkerController::EVENT_INITonServiceWorkerControllerInitInjects Firebase SDK bootstrap into the PWA service worker
LayoutAddons::EVENT_INITonLayoutAddonInitTriggers token registration (after login) or unregistration (after logout) scripts
User::EVENT_AFTER_LOGINonAfterLoginSets the session flag to trigger token registration on next render
User::EVENT_AFTER_LOGOUTonAfterLogoutSets session flags to trigger token unregistration on next render

HTTP endpoints

RouteController actionDescription
POST /fcm-push/token/updateTokenController::actionUpdateRegister/refresh a browser FCM token
POST /fcm-push/token/update-mobile-appTokenController::actionUpdateMobileAppRegister/refresh a mobile app FCM token
POST /fcm-push/token/deleteTokenController::actionDeleteRemove a browser FCM token
POST /fcm-push/token/delete-mobile-appTokenController::actionDeleteMobileAppRemove a mobile app FCM token
GET /fcm-push/statusStatusController::actionIndexReturns HTTP 200 / 404 / 501 JSON to check module health
GET /fcm-push/adminAdminController::actionIndexAdmin configuration page
GET /fcm-push/admin/debugAdminController::actionDebugLists and allows deletion of tokens for the current user

CSRF validation is disabled on TokenController because calls originate from the Firebase JS SDK or the Flutter app, which do not carry a CSRF token.


Console command (testing)

php yii firebase/send-to-user <userId> "<title>" "<message>"

Sends a raw push message to all registered devices for <userId> via all configured drivers. Useful for verifying that configuration is correct without triggering a real notification.


Key classes at a glance

ClassRole
ModuleModule entry point; lazy-loads ConfigureForm and DriverService
ConfigureFormLoads/saves all settings; validates the service account JSON
DriverServiceInstantiates configured drivers; routes to web vs. mobile driver
DriverInterfaceContract every driver must implement
drivers/FcmDirect Firebase delivery via kreait/firebase-php SDK
drivers/ProxyDelivery via the HumHub push relay service
TokenServiceCRUD for FcmUser records (device tokens)
MessagingServiceOrchestrates notification → driver dispatch
NotificationTargetProviderBridge between HumHub's notification system and this module
FcmUserActiveRecord for the fcmpush_user table
MobileAppHelperEmits Flutter JS bridge messages to register/unregister devices
WebAppHelperEmits JS to unregister web browser tokens on logout

Module Information

Price:
Free
Latest version release:
2.3.1 - October 12, 2026
Website:
Compatibility:
HumHub 1.4 - 1.19