redjanym/php-firebase-cloud-messaging
A lightweight PHP library for sending Firebase Cloud Messaging push notifications. Compose notification and data payloads and target device tokens, groups, or topics from Laravel or any PHP application.
1. Install and pin.
composer require redjanym/php-firebase-cloud-messaging
Pin an exact version in composer.json — this is a single-maintainer, low-star package, so you want upgrades to be a deliberate act.
2. Put the credential in config, never in code.
// config/services.php
'fcm' => [
'key' => env('FCM_SERVER_KEY'),
],
Add FCM_SERVER_KEY= to .env and to .env.example (blank). Cache-config in deploy.
3. Register the client once as a singleton.
// app/Providers/AppServiceProvider.php
public function register(): void
{
$this->app->singleton(\Redjanym\Fcm\Client::class, fn () => new \Redjanym\Fcm\Client(
(string) config('services.fcm.key')
));
}
Never new Client(...) inside a controller — you lose testability and re-read config on every call.
4. Where to look first in vendor/redjanym/php-firebase-cloud-messaging/src/:
Message/ — DownstreamMessage, TopicMessage, DeviceGroupMessage, RawMessage, plus Notification and Data value objects.Config/ — AndroidConfig, IosConfig, WebConfig for per-platform overrides.Response/ — the typed response objects returned by send().Exception/ — the dedicated exceptions you should be catching.Read the constructor signatures in Message/ before writing anything; the argument order is the single most common source of "why is my payload empty" confusion. Verify exact class/method names against the repo README for your installed version — this package has churned its naming between releases.
5. First use case — one device token, one notification.
use Redjanym\Fcm\Client;
use Redjanym\Fcm\Message\DownstreamMessage;
use Redjanym\Fcm\Message\Notification;
use Redjanym\Fcm\Message\Data;
$message = new DownstreamMessage(
$user->fcm_token,
new Notification('Order shipped', 'Your order #1042 is on its way'),
new Data([
'order_id' => (string) $order->id, // data values must be strings
'screen' => 'orders',
]),
);
$response = app(Client::class)->send($message);
That is the whole happy path. Everything below is about making it survive production.
Wrap it behind your own contract. Never let Redjanym\Fcm\* types leak into controllers, jobs, or models. One interface, one implementation, one binding:
namespace App\Contracts;
interface PushNotifier
{
public function toToken(string $token, string $title, string $body, array $data = []): void;
public function toTopic(string $topic, string $title, string $body, array $data = []): void;
}
namespace App\Notifications;
use Redjanym\Fcm\Client;
use Redjanym\Fcm\Message\{DownstreamMessage, Notification, Data};
class FcmPushNotifier implements PushNotifier
{
public function __construct(private Client $client) {}
public function toToken(string $token, string $title, string $body, array $data = []): void
{
$this->client->send(new DownstreamMessage(
$token,
new Notification($title, $body),
new Data(array_map('strval', $data)),
));
}
public function toTopic(string $topic, string $title, string $body, array $data = []): void
{
$this->client->send(new TopicMessage(
$topic,
new Notification($title, $body),
new Data(array_map('strval', $data)),
));
}
}
This is the pattern that lets you swap to kreait/laravel-firebase later without touching 40 call sites, and it makes the package's low bus factor a non-event.
Always send from a queued job. FCM round-trips sit on the HTTP request path otherwise.
class SendPush implements ShouldQueue
{
use Queueable, InteractsWithQueue;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public int $timeout = 15;
public function __construct(
public string $token,
public string $title,
public string $body,
public array $data = [],
) {}
public function handle(PushNotifier $push): void
{
$push->toToken($this->token, $this->title, $this->body, $this->data);
}
}
Fan-out: chunk recipient lists. Never hand an unbounded array of tokens to a single message. Chunk at 1000 (the FCM multicast ceiling) and dispatch one job per chunk, or loop inside a dedicated fan-out job with Bus::batch():
foreach (array_chunk($tokens, 1000) as $chunk) {
SendPushChunk::dispatch($chunk, $title, $body, $data);
}
Topic messaging for broadcasts, per-user device groups for multi-device.
TopicMessage) — app-wide announcements, marketing, anything where the audience is defined server-side. Subscribe tokens to a topic like user.{id} if you want per-user broadcasts without tracking device groups.DeviceGroupMessage) — one notification_key per user, mapped to that user's phones/tablets. Remember the 20-tokens-per-key ceiling and that group membership (add/remove) is a separate API call — most projects find topics simpler and just maintain a token table.RawMessage) — when you want full control of the JSON body, e.g. data-only silent sync pushes with no notification block.Inspect the response; don't assume success. Sending "succeeded" at the HTTP layer while individual tokens failed is normal:
$response = $client->send($message);
if ($response->getFailure() > 0) {
foreach ($response->getResults() as $result) {
$error = $result['error'] ?? null;
if (in_array($error, ['NotRegistered', 'InvalidRegistration'], true)) {
// prune this token from your DB
}
}
}
Treat NotRegistered/InvalidRegistration as a terminal outcome — do not throw and retry, or you burn retries on a permanently dead token. Only throw (and therefore retry) on transport/auth errors.
Platform config only where you need it. AndroidConfig (priority high, notification channel id, icon, sound), IosConfig (APNs headers, badge, sound, content-available for silent pushes), WebConfig (icon, click action). Pass them as trailing constructor args on the message. Keep a small config array in config/services.php and build these objects in one place rather than repeating 'high' and channel ids across jobs.
Testing. Http::fake() will not intercept this package — it talks Guzzle directly on its own client instance. Fake at your boundary instead:
$this->mock(PushNotifier::class)
->shouldReceive('toToken')->once()
->with($user->fcm_token, Mockery::any(), Mockery::any(), Mockery::any());
Then test the FCM-specific behaviour (payload shape, chunking, error mapping) in one focused test of the concrete FcmPushNotifier.
The number-one thing to verify: legacy API vs HTTP v1. This package historically wraps the legacy https://fcm.googleapis.com/fcm/send endpoint authenticated by a server key. Google retired that API — server keys no longer work for new projects, and the legacy endpoint was shut down. Before you build on this, confirm which transport the version you installed targets. If it's still the legacy server-key path, treat it as unusable for anything new and write the abstraction layer first, then drop in kreait/laravel-firebase (HTTP v1, service account JSON) behind your PushNotifier interface. Same shape, working auth.
Data payload values must be strings. FCM rejects (or silently mangles) integers, booleans, and nested arrays in the data block. Cast at the boundary — array_map('strval', $data) — and encode any structured value with json_encode() into a single string key. Nested arrays are not supported at all.
4 KB payload limit, total. Title + body + all data keys + platform config. Long notification bodies plus a fat data payload is the usual overrun. Keep data payloads to IDs and let the app fetch the rest.
One response for many recipients. A multicast request returns an aggregate success/failure count plus a per-index results array — the indices only line up with the tokens if you didn't reorder or dedupe between building and sending. Dedupe before sending.
Catch specifically. Wrap sends in try/catch for the package's own exceptions (Exception/) and let everything else bubble to the queue's failed() handler. Distinguish: bad/expired credential → don't retry, alert; network blip → retry with backoff; invalid token → prune, don't retry.
No retry, no logging, no rate limiting inside the package. You own all three. Add a Log::warning with the response's failure count and error codes in the job's handle(), and a failed(Throwable $e) method that logs the token (hashed) and reason.
Timeouts are yours to control. The underlying Guzzle client is constructed by the package, not injected — you can't easily swap in a handler stack or a custom timeout. So set public $timeout on the job and rely on queue-level supervision. Similarly, no Guzzle middleware means no built-in request logging; instrument at your job layer.
Secrets hygiene. The server key is a full-privilege credential for your Firebase project. .env only, never config('services.fcm.key') echoed in exception messages, never in a bug report. Rotate immediately if it lands in a log or a repo.
Token lifecycle is your job. FCM tokens rotate on reinstall, restore-from-backup, and app data clear. Store a token per device, keyed by an install id, and reconcile on every app launch where the client posts its current token. Delete on logout. The NotRegistered pruning above is your cleanup backstop — run it as a scheduled sanity sweep, not just inline.
Extension points are thin, and that's the point. There's no service provider, no publishable config, no facade, no events. That means zero framework coupling (good — it works in plain PHP too) and zero batteries included (you write the binding, the config, the queueing, the pruning). Accept that trade explicitly, or the project will accumulate new Client(...) calls scattered across controllers.
Bus factor. 37 stars, one maintainer, and a package that exists mostly to build a JSON array. The real code you're buying is the Message/Config builders and the response parsing — perhaps 300 lines. Keep the wrapper so thin that replacing it is a single-file change, and never let its objects appear in a model, migration, or API resource.
How can I help you explore Laravel packages today?