Article

Sending Android Push Notifications with FCM HTTP v1 in Laravel (Part 2 of 4)

D

Dinesh Wijethunga

June 7, 2026 5 min readBeginner 27 views
πŸ“

Why the Old Way No Longer Works

Until mid-2024, you could send an Android push notification with a single static server key in a header. That API is gone. The replacement (HTTP v1) authenticates as a Google service account using a short-lived OAuth2 access token. This token expires every 60 minutes and must be renewed automatically.

What You Need from Firebase Console

  1. Firebase console β†’ Project Settings β†’ Service accounts.
  2. Click Generate new private key β†’ download the JSON file.
  3. Store it on your server outside the web root (e.g. storage/fcm/).

If you have two separate apps (customer + driver), download one JSON file per Firebase project.

Replace these placeholders in the config below:

  • Credential file paths β†’ absolute paths to your downloaded JSON files
  • Project IDs come from inside the JSON file (project_id key)

Config File

At Modules/PushNotification/config/config.php. The config key uses a hyphen β€” this matches the nwidart/laravel-modules folder naming convention.

<?php

return [
    'fcm' => [
        'customer' => [
            'credentials_path' => env('FCM_CUSTOMER_CREDENTIALS_PATH', storage_path('fcm/customer-firebase-adminsdk.json')),
            'project_id'       => env('FCM_CUSTOMER_PROJECT_ID', 'your-customer-project-id'),
        ],
        'driver' => [
            'credentials_path' => env('FCM_DRIVER_CREDENTIALS_PATH', storage_path('fcm/driver-firebase-adminsdk.json')),
            'project_id'       => env('FCM_DRIVER_PROJECT_ID', 'your-driver-project-id'),
        ],
    ],

    'apns' => [
        // covered in Part 3
    ],
];

.env:

FCM_CUSTOMER_CREDENTIALS_PATH=/var/www/storage/fcm/customer-firebase-adminsdk.json
FCM_CUSTOMER_PROJECT_ID=your-customer-project-id
FCM_DRIVER_CREDENTIALS_PATH=/var/www/storage/fcm/driver-firebase-adminsdk.json
FCM_DRIVER_PROJECT_ID=your-driver-project-id

The FcmService Class

Place this at Modules/PushNotification/app/Services/FcmService.php.

Uses symfony/http-client β€” install it with composer require symfony/http-client.

<?php

namespace Modules\PushNotification\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\PushNotification\Models\DeviceToken;
use Symfony\Component\HttpClient\HttpClient;

class FcmService
{
    private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
    private const FCM_URL   = 'https://fcm.googleapis.com/v1/projects/%s/messages:send';

    /**
     * Deliver a push notification to a single FCM device token.
     *
     * @param  string      $token        The FCM registration token for the device
     * @param  string      $appType      'customer' or 'driver' β€” picks the right Firebase project
     * @param  array|null  $notification Visible alert ({title, body}). Null = silent/data-only.
     * @param  array       $data         Background data payload (all values cast to strings)
     * @param  string      $priority     'high' or 'normal'
     */
    public function deliver(
        string $token,
        string $appType,
        ?array $notification,
        array  $data     = [],
        string $priority = 'high',
    ): void {
        $credentials = $this->loadCredentials($appType);
        $accessToken = $this->getAccessToken($credentials, $appType);
        $projectId   = $credentials['project_id'];

        $message = ['token' => $token];

        if ($notification !== null) {
            $message['notification'] = $notification;
        }

        if (!empty($data)) {
            // FCM data values must all be strings
            $message['data'] = array_map('strval', $data);
        }

        $message['android'] = ['priority' => strtoupper($priority)];

        $client = HttpClient::create();

        try {
            $response = $client->request('POST', sprintf(self::FCM_URL, $projectId), [
                'headers' => [
                    'Authorization' => 'Bearer ' . $accessToken,
                    'Content-Type'  => 'application/json',
                ],
                'json' => ['message' => $message],
            ]);

            $status = $response->getStatusCode();
            $body   = $response->toArray(throw: false);

            if ($status !== 200) {
                $error = $body['error']['details'][0]['errorCode']
                    ?? $body['error']['message']
                    ?? 'unknown';

                // Token is permanently invalid β€” delete it so we stop sending to it
                if (in_array($error, ['UNREGISTERED', 'INVALID_ARGUMENT'], true)) {
                    DeviceToken::removeToken($token);
                    Log::info("FcmService: removed invalid token [{$error}]");
                    return;
                }

                Log::warning("FcmService: delivery failed [{$status}] {$error}", [
                    'token' => substr($token, 0, 20),
                ]);
            }
        } catch (\Throwable $e) {
            Log::error("FcmService: exception β€” {$e->getMessage()}");
        }
    }

    // ── OAuth2 service account ────────────────────────────────────

    private function getAccessToken(array $credentials, string $appType): string
    {
        $cacheKey = "fcm_access_token_{$appType}";

        // Cache for 55 minutes β€” tokens expire after 60
        return Cache::remember($cacheKey, now()->addMinutes(55), function () use ($credentials) {
            $jwt    = $this->buildJwt($credentials);
            $client = HttpClient::create();

            $response = $client->request('POST', self::TOKEN_URL, [
                'body' => http_build_query([
                    'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
                    'assertion'  => $jwt,
                ]),
                'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
            ]);

            return $response->toArray()['access_token'];
        });
    }

    private function buildJwt(array $credentials): string
    {
        $now = time();

        $header  = $this->encodeBase64Url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
        $payload = $this->encodeBase64Url(json_encode([
            'iss'   => $credentials['client_email'],
            'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
            'aud'   => self::TOKEN_URL,
            'iat'   => $now,
            'exp'   => $now + 3600,
        ]));

        $signingInput = $header . '.' . $payload;
        $privateKey   = openssl_pkey_get_private($credentials['private_key']);

        openssl_sign($signingInput, $signature, $privateKey, OPENSSL_ALGO_SHA256);

        return $signingInput . '.' . $this->encodeBase64Url($signature);
    }

    private function loadCredentials(string $appType): array
    {
        // Config key uses a hyphen β€” matches the module folder name
        $path = $appType === 'driver'
            ? config('push-notification.fcm.driver.credentials_path')
            : config('push-notification.fcm.customer.credentials_path');

        if (!$path || !file_exists($path)) {
            throw new \RuntimeException(
                "FCM credentials file not found for [{$appType}]: {$path}"
            );
        }

        return json_decode(file_get_contents($path), true);
    }

    private function encodeBase64Url(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
}

How the OAuth2 Flow Works (Plain English)

Think of it like a visitor badge at a corporate office:

  1. You present your employee ID (the service account JSON) at the front desk.
  2. The desk signs a short-lived pass (a JWT) and sends it to Google's token server.
  3. Google checks it and hands back a 60-minute access token (your badge).
  4. You use that badge on every FCM request for the next hour.
  5. Cache::remember() stores the badge β€” so steps 1–3 only happen once per hour, not once per notification.

buildJwt() handles step 2: RS256-signs a JWT with the private key from the downloaded JSON.

Dead Token Cleanup

When FCM returns UNREGISTERED or INVALID_ARGUMENT, the app was uninstalled or the token expired. We call DeviceToken::removeToken($token) immediately. The next time the user installs the app and opens it, saveToken() from Part 1 registers a fresh token.

Deploying this on a VPS? Hostinger's cloud VPS plans come with PHP 8.3, MySQL, and Redis already available β€” everything this module needs.

D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.