Sending Android Push Notifications with FCM HTTP v1 in Laravel (Part 2 of 4)
Dinesh Wijethunga
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
- Firebase console β Project Settings β Service accounts.
- Click Generate new private key β download the JSON file.
- 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_idkey)
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:
- You present your employee ID (the service account JSON) at the front desk.
- The desk signs a short-lived pass (a JWT) and sends it to Google's token server.
- Google checks it and hands back a 60-minute access token (your badge).
- You use that badge on every FCM request for the next hour.
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.
Reviews & Ratings
Sign in to leave a review.
