The Ultimate Guide: Push Notifications with Firebase (FCM v1) in Laravel and Flutter

- Andrés Cruz - ES En español

Implementing push notifications today means working with Firebase's FCM v1 protocol, which replaced the legacy Server Keys system in June 2024. In this guide, we will look at how to set up the complete flow: from the Laravel backend to receiving and displaying notifications in an Android Flutter app.

1. Firebase Project Setup

Before writing a single line of code, you need to prepare the environment in the Firebase Console. These steps are the foundation for everything else.

  • Create the Project: Go to the Firebase Console and create a new project. If you already have one, you can use it directly.
  • Add the Android App: Register your app using the applicationId defined in your build.gradle (e.g., com.desarrollo.libre). This identifier must exactly match your Flutter project's identifier.
  • Download google-services.json: This file contains your app's credentials and acts as the "bridge" between your Android code and Firebase services.

    ⚠️ IMPORTANT: The file must be placed in the path android/app/google-services.json. If you place it in the root of the android/ folder, the Google Services plugin will fail silently during compilation.
  • Server Credentials: Go to "Project settings" → "Service accounts" and generate a new private key in JSON format. This file is what Laravel will use to authenticate with the FCM v1 API in order to send messages.

2. Implementation in Laravel

On the backend, we will use the kreait/laravel-firebase library to interact with the FCM v1 API. Besides installing the package via Composer, it is recommended to manually register the ServiceProvider in bootstrap/providers.php to avoid issues with auto-discovery in certain production environments:

bootstrap/providers.php

return [
    App\Providers\AppServiceProvider::class,
    // ... other providers
    Kreait\Laravel\Firebase\ServiceProvider::class, // Mandatory manual registration
];

With the provider registered, we can create the service that encapsulates the sending logic. We use injection through the Messaging::class contract, which makes the code easier to test and maintain:

app/Services/FirebaseService.php

public function sendPushNotification(string $title, string $body, ?string $imageUrl = null, string $topic = 'all', array $data = [])
{
    if (app()->bound(\Kreait\Firebase\Contract\Messaging::class)) {
        $messaging = app(\Kreait\Firebase\Contract\Messaging::class);

        $notification = \Kreait\Firebase\Messaging\Notification::create($title, $body, $imageUrl);

        $message = \Kreait\Firebase\Messaging\CloudMessage::new()
            ->withTopic($topic) // Send to a global topic
            ->withNotification($notification)
            ->withData($data);

        $messaging->send($message);
        return true;
    }
    return false;
}

The $topic parameter acts as a broadcast channel: all devices subscribed to that topic will receive the message. The default value of 'all' is used for global notifications, but you can create more specific topics if you need to segment your audience (for example, 'premium' or 'es_users').

3. Android Configuration in Flutter

The Android portion requires a couple of critical adjustments: one to enable modern Java features on older versions of the operating system, and another to ensure that background notifications are displayed correctly.

Enable Desugaring (Core Library Desugaring)

If you are using flutter_local_notifications (v21+), you need to enable support for Java 8+ APIs. This is mandatory because some of its internal dependencies use classes that did not exist in older versions of Android.

android/app/build.gradle.kts

android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true // CRITICAL
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

What does "isCoreLibraryDesugaringEnabled" do?

This option acts as a "translator" at compile time. It allows packages using modern Java APIs to work without errors on older Android versions (such as Android 5.0 or 6.0). Without this, the CheckAarMetadataWorkAction error interrupts the build because the compiler detects an incompatibility between the new libraries and the minimum Android version your app supports.

Manifest Configuration

We add the notification permission (required since Android 13) and define the default channel ID. This ID is what FCM will use when the app is in the background or closed to automatically display the notification, bypassing the Dart code.

android/app/src/main/AndroidManifest.xml

<manifest ...>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

    <application ...>
        <meta-data
            android:name="com.google.firebase.messaging.default_notification_channel_id"
            android:value="high_importance_channel" />
    </application>
</manifest>

4. Implementation in Flutter (Dart)

We create the FirebaseApi service to handle three responsibilities: SDK initialization, topic subscription, and listening for messages when the app is in the foreground. Background notifications are managed directly by FCM using the manifest configuration.

lib/api/firebase_api.dart

class FirebaseApi {
  final _firebaseMessaging = FirebaseMessaging.instance;
  final _localNotifications = FlutterLocalNotificationsPlugin();

  // High importance channel for Android (must match the one in the Manifest)
  final _androidChannel = const AndroidNotificationChannel(
    'high_importance_channel',
    'Important Notifications',
    importance: Importance.max,
  );

  Future<void> initNotifications() async {
    // 1. Request permissions from the system (Android 13+)
    await _firebaseMessaging.requestPermission();

    // 2. Subscribe to the 'all' topic (must match the backend topic)
    await _firebaseMessaging.subscribeToTopic('all');

    // 3. Initialize flutter_local_notifications to handle the foreground
    await initLocalNotifications();

    // 4. Listener for messages when the app is open
    FirebaseMessaging.onMessage.listen((RemoteMessage message) {
      final notification = message.notification;
      if (notification == null) return;

      _localNotifications.show(
        notification.hashCode,
        notification.title,
        notification.body,
        NotificationDetails(
          android: AndroidNotificationDetails(
            _androidChannel.id, _androidChannel.name,
            icon: '@mipmap/ic_launcher',
          ),
        ),
      );
    });
  }
}

It is worth understanding why we need flutter_local_notifications for the foreground: when the app is active, FCM delivers the message silently through the onMessage listener, but it does not automatically display any banner. That is when we take control and trigger the visual notification using the local plugin.

You can call initNotifications() from main.dart, but a more recommended practice is to first display a "soft prompt" to ask the user for permission before triggering the native system dialog. This significantly improves the opt-in rate, as the user understands the context before seeing the Android permission prompt:

lib/widgets/notification_soft_prompt.dart

import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:desarrollolibre/widgets/premium_widgets.dart';
import 'package:desarrollolibre/api/firebase_api.dart';
import 'package:desarrollolibre/utils/user_preference.dart';

class NotificationSoftPrompt extends StatefulWidget {
  final VoidCallback? onActionCompleted;

  const NotificationSoftPrompt({
    super.key,
    this.onActionCompleted,
  });

  @override
  State<NotificationSoftPrompt> createState() => _NotificationSoftPromptState();
}

class _NotificationSoftPromptState extends State<NotificationSoftPrompt> {
  bool _showSoftPrompt = false;
  final _userPreference = UserPreference();

  @override
  void initState() {
    super.initState();
    _checkSoftPrompt();
  }

  void _checkSoftPrompt() async {
    final hasNativePermission = await FirebaseApi().getNotificationStatus();
    final wasDismissed = _userPreference.notificationSoftPromptDismissed;
    if (!hasNativePermission && !wasDismissed) {
      if (mounted) {
        setState(() {
          _showSoftPrompt = true;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    if (!_showSoftPrompt) return const SizedBox.shrink();

    return Container(
      margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      child: GlassContainer(
        padding: const EdgeInsets.all(20),
        borderRadius: 20,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Container(
                  padding: const EdgeInsets.all(10),
                  decoration: BoxDecoration(
                    color: const Color(0xFF6366F1).withOpacity(0.15),
                    shape: BoxShape.circle,
                  ),
                  child: const Icon(
                    Icons.notifications_active_outlined,
                    color: Color(0xFF6366F1),
                    size: 28,
                  )
                      .animate(onPlay: (c) => c.repeat())
                      .shake(delay: 3.seconds, duration: 800.ms, hz: 4),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const Text(
                        "notificationSoftPromptTitle",
                        style: TextStyle(
                          color: Colors.white,
                          fontSize: 18,
                          fontWeight: FontWeight.bold,
                        ),
                      ).tr(),
                      const SizedBox(height: 6),
                      const Text(
                        "notificationSoftPromptSubtitle",
                        style: TextStyle(
                          color: Colors.white70,
                          fontSize: 14,
                          height: 1.4,
                        ),
                      ).tr(),
                    ],
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.end,
              children: [
                TextButton(
                  onPressed: () {
                    _userPreference.notificationSoftPromptDismissed = true;
                    setState(() {
                      _showSoftPrompt = false;
                    });
                    if (widget.onActionCompleted != null) {
                      widget.onActionCompleted!();
                    }
                  },
                  child: const Text(
                    "maybeLater",
                    style: TextStyle(
                      color: Colors.white70,
                      fontWeight: FontWeight.bold,
                    ),
                  ).tr(),
                ),
                const SizedBox(width: 12),
                ElevatedButton(
                  style: ElevatedButton.styleFrom(
                    backgroundColor: const Color(0xFF6366F1),
                    foregroundColor: Colors.white,
                    elevation: 4,
                    padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(12),
                    ),
                  ),
                  onPressed: () async {
                    final isGranted = await FirebaseApi().enableNotifications();
                    _userPreference.notificationsEnabled = isGranted;
                    _userPreference.notificationSoftPromptDismissed = true;
                    setState(() {
                      _showSoftPrompt = false;
                    });
                    if (widget.onActionCompleted != null) {
                      widget.onActionCompleted!();
                    }
                  },
                  child: const Text(
                    "activateNow",
                    style: TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ).tr(),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

The widget checks two conditions before being displayed: that the user does not already have the native permission enabled, and that they have not dismissed the prompt previously. If both are met, the banner appears. Otherwise, it returns an invisible SizedBox.shrink(), taking up no space in the widget tree.

Lessons Learned and Troubleshooting

  • Why weren't notifications arriving? The backend was sending successfully to the all topic, but the app was never subscribing to it from the client side. The solution is simple but easy to forget: subscribeToTopic('all') must run during initialization.
  • CheckAarMetadata Error: This error appears during compilation when there is an incompatibility between library versions and your app's minSdkVersion. It is resolved by enabling isCoreLibraryDesugaringEnabled in build.gradle.kts.
  • Google Services Plugin failed: Make sure that google-services.json is inside android/app/ and not in the root folder android/. A single level difference in the path is enough for the plugin not to find it.

How does the complete flow work?

PhaseTechnologyKey Action
1. BroadcastLaravel + KreaitSending the message to the "all" topic using the FCM v1 API.
2. GatewayFirebase Cloud MessagingAuthentication via Google Admin SDK (JSON) and routing to the device.
3. ReceptionFlutter (Android)Topic subscription and local rendering based on the app state (foreground/background).

Step 1: Configuration in Firebase Console

  1. Create Project: Go to the Firebase Console and create a new project.
  2. Generate Laravel Credentials: Project settings > Service accounts > "Generate new private key". Download the JSON and save it as firebase_credentials.json in the root of your Laravel project.
  3. Register Android App: Add an Android app with the package name of your Flutter project (e.g., com.desarrollolibre.blog). Download google-services.json.

Step 2: Implementation in Laravel

To keep the system stable, we use the kreait/laravel-firebase wrapper and manually register the ServiceProvider to avoid auto-discovery failures.

1. Registration in bootstrap/providers.php:

return [
    App\Providers\AppServiceProvider::class,
    // ... other providers
    Kreait\Laravel\Firebase\ServiceProvider::class, // Mandatory manual registration
];

2. Service Logic (FirebaseService.php):

use Kreait\Firebase\Contract\Messaging;

public function sendPushNotification($title, $body, $topic = 'all') {
    $messaging = app(Messaging::class); // Injection via Contract
    
    $message = CloudMessage::new()
        ->withTopic($topic) // Correct syntax v7+
        ->withNotification(Notification::create($title, $body));

    $messaging->send($message);
}

Step 3: Configuration in Flutter (Android Studio)

Configure your mobile environment to react to server signals:

  • google-services.json: Paste it inside android/app/.
  • build.gradle (Project): Add google-services to the plugins.
  • User Subscription: In your main.dart or API Service, subscribe to the topic that the backend is broadcasting.
// In lib/api/firebase_api.dart
Future<void> initNotifications() async {
    await _firebaseMessaging.requestPermission();
    
    // The secret: Subscribe to the same topic sent by Laravel
    await _firebaseMessaging.subscribeToTopic('all'); 
    
    // Listen to messages in the foreground
    FirebaseMessaging.onMessage.listen((message) {
        showLocalNotification(message); // Using flutter_local_notifications
    });
}
Video thumbnail

Design and Implementation of a SCALABLE Notification System with Laravel

Very closely aligned with the previous module and something you can also use in conjunction with the previous implementation, I will show you how to implement a simple notification system that, although it might initially seem basic, offers great flexibility and key architectural concepts. Additionally, we will analyze the importance of thoroughly understanding the code we generate or obtain through artificial intelligence assistance.

Initially, I used a prompt to generate the general structure of the notifications. The AI suggested a topic-based architecture, common in integrations with services like Firebase. This structure proved very valuable for building a multichannel system instead of one limited to a single workflow.

The ultimate goal is to allow sending notifications from a local admin panel to the user. To view the source code and the complete Firebase integration guide, you can check the documentation I have prepared on the platform.

Database Structure

The system relies mainly on two tables within the database:

  • site_notifications: Stores global notifications sent from the admin panel. Contains main fields like title (title), body (body), and topic (topic).
  • site_notification_user: Functions as a pivot table to manage the read status of each notification per user.

To track which notifications a user has viewed, we record the user identifier (user_id) and the notification identifier (notification_id). An updateOrCreate method is used to ensure no duplicate records are created when updating the read status.

#[Fillable(['title', 'body', 'topic', 'action_type', 'action_value'])]
class SiteNotification extends Model
#[Fillable(['user_id', 'notification_id', 'read_at'])]
class SiteNotificationUser extends Pivot

Optimizing Data Cleanup

Keeping all read records indefinitely can clutter the database as users and years of usage increase. Since users rarely review old notifications, it is convenient to define a cleanup policy.

To solve this, we implemented the cleanOldRecords method in the pivot model, which automatically deletes notification history older than four months:

public static function cleanOldRecords(User $user): void
{
    self::where('user_id', $user->id)
        ->where('created_at', '<', now()->subMonths(4))
        ->delete();
}

When querying notifications via the API, we also apply a filter so the minimum date matches this four-month period. This avoids inconsistencies between recorded notifications and those displayed on the user dashboard.

Endpoints and Consumption from the Frontend

Consuming the system from the Vue.js interface is handled through three main endpoints defined in the API controller:

  1. index: Returns unread notifications and the counter displayed on the floating icon in the panel.
  2. all: Returns all paginated notifications within the allowed timeframe (four months).
  3. markAsRead: Receives a notification identifier and updates the read date (read_at) in the pivot table.
<?php

namespace App\Http\Controllers\Api;

use App\Actions\Notification\GetTopic;
use App\Http\Resources\SiteNotificationResource;
use App\Models\SiteNotification;
use App\Models\SiteNotificationUser;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class NotificationController extends ApiResponseController
{
    // private function getUserTopics(User $user): array
    // {
    //      $topics = ['all', 'news', 'offers'];

    //      // Enrolled Tutorials
    //      $enrolledTutorialIds = FilePayment::where('user_id', $user->id)
    //          ->where('file_paymentable_type', Tutorial::class)
    //          ->whereNull('unenroll')
    //          ->pluck('file_paymentable_id');

    //      if ($enrolledTutorialIds->isNotEmpty()) {
    //          $tutorialSlugs = Tutorial::whereIn('id', $enrolledTutorialIds)->pluck('url_clean')->toArray();
    //          foreach ($tutorialSlugs as $slug) {
    //              $topics[] = $slug;
    //              $topics[] = "course-{$slug}";
    //          }
    //          foreach ($enrolledTutorialIds as $id) {
    //              $topics[] = "course-{$id}";
    //          }
    //      }

    //      // Enrolled Books
    //      $enrolledBookIds = FilePayment::where('user_id', $user->id)
    //          ->where('file_paymentable_type', Book::class)
    //          ->whereNull('unenroll')
    //          ->pluck('file_paymentable_id');

    //      if ($enrolledBookIds->isNotEmpty()) {
    //          $bookSlugs = Book::whereIn('id', $enrolledBookIds)->pluck('url_clean')->toArray();
    //          foreach ($bookSlugs as $slug) {
    //              $topics[] = $slug;
    //              $topics[] = "book-{$slug}";
    //          }
    //          foreach ($enrolledBookIds as $id) {
    //              $topics[] = "book-{$id}";
    //          }
    //      }

    //      return $topics;
    // }

    /**
     * Get notifications available for the authenticated user that are unread.
     */
    public function index(Request $request): JsonResponse
    {
        return $this->successResponse(
            getNotificationsUser(user: $this->getUser())
        );
    }

    /**
     * Get all notifications for the authenticated user, paginated.
     */
    public function all(Request $request): JsonResponse
    {
        $user = $this->getUser();
        if (! $user) {
            return $this->errorResponse(null, 401, 'Unauthorized');
        }

        $topicAction = app(GetTopic::class);
        $topics = $topicAction->getUserTopics($user);

        SiteNotificationUser::cleanOldRecords($user);

        $minDate = max($user->created_at, now()->subMonths(4));

        $notifications = SiteNotification::whereIn('topic', $topics)
            ->where('created_at', '>=', $minDate)
            ->with([
                'users' => function ($query) use ($user) {
                    $query->where('user_id', $user->id);
                },
            ])
            ->latest()
            ->paginate(15);

        return $this->successResponse(
            SiteNotificationResource::collection($notifications)->response()->getData(true)
        );
    }

    /**
     * Mark a specific notification as read.
     */
    public function markAsRead(Request $request, int $id): JsonResponse
    {
        $user = $this->getUser();
        if (! $user) {
            return $this->errorResponse(null, 401, 'Unauthorized');
        }

        $notification = SiteNotification::find($id);
        if (! $notification) {
            return $this->errorResponse(null, 404, 'Notification not found');
        }

        $pivot = SiteNotificationUser::updateOrCreate(
            [
                'user_id' => $user->id,
                'notification_id' => $notification->id,
            ],
            [
                'read_at' => now(),
            ]
        );

        return $this->successResponse($pivot, 200, 'Notification marked as read');
    }
}

The Importance of Topics

The class responsible for defining topics is a fundamental piece in this architecture. It allows targeting messages to general audiences or specific users without coupling the notification to a single module.

class GetTopic
{
    /**
     * Get topics for the authenticated user
     *
     * @param User
     */
    public function getUserTopics(User $user): array
    {
        $topics = ['all', 'news', 'offers', "user:{$user->id}"];
        return $topics;
    }
}    

For example, we can define a general topic like 'all' for global announcements, or use a convention based on the user identifier like "user:{id}" for private messages:

public static function notifyUser(
    int $recipientId,
    string $authorName,
    string $message,
    string $actionValue,
    string $actionType = 'post'
): self {
    return self::create([
        'title'        => 'New reply from: ' . Str::limit($authorName, 20),
        'body'         => Str::limit($message, 120),
        'topic'        => "user:{$recipientId}",
        'action_type'  => $actionType,
        'action_value' => $actionValue,
    ]);
}

Thanks to this approach, when querying for a particular user, the backend only retrieves notifications for topics to which that user is subscribed.

Eventual Integration and Software Architecture

A practical use case for this system occurs when receiving a message through the contact module. If the user is authenticated, we can open a direct communication channel by reusing the comments entity (commentables) and immediately issue a private notification to the administrator:

private function commentable(User $user, ModelsContact $contact)
{
    Comment::create([
        'user_id'          => $user->id,
        'title'            => $contact->name . ' ' . $contact->surname,
        'description'      => $contact->message,
        'commentable_id'   => $contact->id,
        'commentable_type' => ModelsContact::class,
    ]);

    SiteNotification::notifyUser(
        recipientId: 1, // Administrator ID
        authorName: $contact->name ?? $user->name ?? 'User',
        message: $contact->message,
        actionValue: "/contact/{$contact->id}"
    );
}

This type of integration demonstrates the importance of understanding software architecture. Applications are living entities that evolve over time; reusing existing models (such as polymorphic comment relationships or the notification topic system) avoids unnecessary code duplication and maintains long-term project maintainability.

Configure push notifications with Firebase FCM v1, Laravel, and Flutter for Android. Includes solutions for common errors such as CheckAarMetadata and Google Services.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.