Skip to content
ArticleLaravel

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

D

Dinesh Wijethunga

June 7, 2026Updated Aug 2, 2026 5 min readBeginner 235 views
ShareX / TwitterLinkedIn
Sending Android Push Notifications with FCM HTTP v1 in Laravel (Part 2 of 4)

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.

Was this post helpful?

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

You might also like