tomatophp/filament-fcm
TomatoPHP's Filament FCM brings Firebase Cloud Messaging into your Filament admin panel. Send push notifications to devices, manage FCM credentials and notification templates, and monitor delivery, all from a clean, native Laravel interface.
Check the version matrix before you type composer require. This is a thin adapter with a hard filament/filament constraint, and Filament majors break plugins. Open composer.json on the tag you intend to install and read the require block: the Filament constraint, the illuminate/* constraints, the PHP floor. If your app is on Filament 4 and the constraint says ^3.0, stop — no amount of local patching turns that into a good Monday. Scan require for sibling tomatophp/filament-* packages too; they auto-discover service providers and can collide with plugins you already run.
# pin it — no caret on a single-maintainer plugin
composer require tomatophp/filament-fcm:1.2.3
php artisan vendor:publish --tag="filament-fcm-config"
php artisan vendor:publish --tag="filament-fcm-migrations"
php artisan migrate
Read four files before writing any code:
config/filament-fcm.php — it tells you the transport. If you see anything resembling a legacy server key (AAAA..., FCM_SERVER_KEY), that API is gone; you need HTTP v1 with a service-account JSON. That's a go/no-go item, not a config tweak.user_id or to a polymorphic notifiable. This decides how your client registration endpoint looks.src/ — the admin surface, and the fastest thing to demo to your team.kreait/firebase-php / laravel-notification-channels/fcm. If it wraps a known library, you can swap the channel later without touching the package.Credentials are the real work. Create a Firebase project, generate a service-account JSON key, and put the JSON path (not the JSON blob) in env — FCM_CREDENTIALS=/run/secrets/firebase.json, backed by your secret manager. Register the APNs auth key (iOS) and a VAPID/web-push certificate (web) in the Firebase console; the package cannot do this for you, and this is where most "push doesn't work" tickets actually live.
Register the plugin in your PanelProvider only if the package ships a plugin class:
->plugin(\TomatoPHP\FilamentFcm\FilamentFcmPlugin::make())
Verify it registers cleanly in a non-default panel — most production apps have at least two (admin + tenant), and single-panel assumptions are a common plugin bug.
First use case: push to yourself. Insert a device token row for your own user, open the Filament page or action the package provides, send a notification titled hello, and confirm it arrives on a real device with the app backgrounded. Physically holding the device — not an emulator, not a web tab — is the only test that proves APNs/web-push credentials are wired. Budget half a day for that loop: the push takes minutes, the credential debugging is the rest.
Treat the token table as a first-class domain object, not package internals. Tokens come from your clients, never from FCM. You need an authenticated endpoint that upserts:
// routes/api.php — bearer-authenticated, user derived from the token, never from the body
Route::post('/devices', [DeviceTokenController::class, 'store'])->middleware('auth:sanctum');
public function store(StoreDeviceToken $request)
{
$request->user()->deviceTokens()->updateOrCreate(
['token' => $request->string('token')],
[
'platform' => $request->string('platform'), // ios | android | web
'device_name' => $request->string('device_name'),
'last_seen_at' => now(),
],
);
return response()->noContent();
}
Index on (user_id, token) and make token unique. Never accept a user_id from the client — an unbound token registration endpoint is an account-takeover primitive.
Send from the notification layer, not from the Filament page. The package's value is that "push" becomes another channel on a notification you already have:
final class ApprovalEscalated extends Notification implements ShouldQueue
{
public function __construct(public Approval $approval) {}
public function via($notifiable): array
{
return ['database', FilamentFcmChannel::class]; // verify the actual FQCN in the package
}
public function toArray($notifiable): array
{
return ['title' => 'Approval escalated', 'url' => "/approvals/{$this->approval->id}"];
}
public function toFcm($notifiable): array
{
return [
'title' => 'Approval escalated',
'body' => "Order #{$this->approval->reference} needs a decision",
'data' => ['url' => "/approvals/{$this->approval->id}"], // strings only
];
}
public function routeNotificationForFcm($notifiable)
{
return $notifiable->deviceTokens()->pluck('token')->all();
}
}
Keep the payload thin. FCM caps out around 4KB total and every data value must be a string — an integer id will fail serialization. Put deep-link intent in data, keep notification for lock-screen visibility, and let the client render the rest.
Then let the Filament UI be a manual override. The admin page is excellent for "send a test," "announce maintenance to the ops team," and "resend a failed alert." It's a poor primary authoring tool — don't build campaign logic in a Filament form. Wire table actions to dispatch the same notification class:
Tables\Actions\Action::make('push')
->form([TextInput::make('body')->required()])
->action(fn (User $record, array $data) => $record->notify(new ManualPush($data['body'])))
->visible(fn () => auth()->user()?->can('send.pushes'));
Batch and queue. One user with five stale devices should be one job, not five inline HTTP calls inside a Livewire request:
final class SendPushJob implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function handle(PushChannel $push): void
{
foreach ($this->user->deviceTokens as $device) {
try {
$push->send($device->token, $this->payload);
} catch (TokenUnregistered $e) {
$device->delete(); // prune immediately
} catch (\Throwable $e) {
report($e); // don't fail the whole batch for one device
}
}
}
}
Wrap it behind your own interface — highest-leverage hour you'll spend. Define PushChannel with send, registerToken, revokeToken, pruneInvalid; bind the package as the first adapter and a FakePushChannel in tests. Tests should never hit FCM:
$this->app->instance(PushChannel::class, $fake = new FakePushChannel());
$this->post('/approvals/1/escalate');
$fake->assertSent(fn ($m) => $m->token === $token);
Topics for broadcast, tokens for people. A small ops team, a tenant-wide announcement, or "all devices in this region" is a topic send. Per-user approvals are token sends. Mixing the two (pushing per-user messages through a topic) is a reliable way to notify the wrong cohort.
Feature-flag the send path. A PUSH_ENABLED check at the top of the job lets you turn push off globally in thirty seconds when tokens start failing, rather than reverting a deploy.
Legacy vs HTTP v1 is the trap. If the config accepts a server key rather than a service-account JSON, the package predates the legacy shutdown. FCM returns an opaque 401 and you will spend an hour blaming your credentials. Check the HTTP client code path before you invest in the spike.
Tokens are ephemeral and stale tokens fail silently. FCM rotates registration tokens on reinstall, backup restore, and sometimes spontaneously. Your client must call onTokenRefresh and re-register; your backend must delete tokens on UNREGISTERED / registration-token-not-registered errors. Without pruning, the token table grows monotonically and every send wastes work on dead rows. Schedule a nightly job that drops tokens with last_seen_at older than N weeks and mark-invalid-on-send-error. That's the difference between a demo and something operable.
FCM accepting a send is not delivery. A 200 with a message id means the message entered FCM — nothing about APNs, the OS, or whether a human saw it. Log the message id, and record a client-side "received" ping so you can compute a real delivery rate. Alert on send-error rate per platform: an expired APNs key silently zeroes iOS delivery while Android keeps humming, and nobody notices until a decision is late.
config:clear after every credential change. The service-account path is read from config; with config:cache in play, editing .env changes nothing. This burns people every time.
Queued ≠ sent, and no worker = silence. Confirm QUEUE_CONNECTION and that a worker is actually running before concluding the package is broken. Check failed_jobs first.
Pin exactly, and re-test on every Filament bump. Add "send one push, end to end" to your Filament upgrade checklist. Plugin breakage usually surfaces as a blank widget or a container resolution error, not a loud failure.
Watch the transitive tree. Run composer why tomatophp/filament-fcm before shipping. Sibling packages can register competing notification drivers or duplicate service providers; two channels claiming the same name resolve non-deterministically.
Multi-panel and tenancy. If you use Filament tenancy, verify the plugin's resources scope to the current tenant. Token tables usually need a tenant_id column the package does not ship — add it in your own migration and scope routeNotificationForFcm accordingly.
Never put secrets or full PII in the payload. Notification bodies render on lock screens and in notification centre. "Refund for $14,230 approved" is an information leak. Send "You have a pending approval" plus a deep link, and let the app fetch details after auth.
Keep a fork with upstream as a remote. git clone, add upstream, push to your org, point composer at the VCS repo. It converts "upstream went quiet" from an incident into a composer update. The code surface is small — absorbing it later is realistic if you kept the PushChannel seam.
Debug in this order: (1) does the token exist and is it current for the right user; (2) is the service-account JSON loading (file permissions, base64 -d check); (3) does a raw HTTP v1 send via curl + gcloud auth application-default print-access-token succeed — that isolates FCM from the package in one step; (4) only then read package code. Nine times in ten the answer is credentials or a stale token, not the adapter.
How can I help you explore Laravel packages today?