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
applicationIddefined in yourbuild.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 pathandroid/app/google-services.json. If you place it in the root of theandroid/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
alltopic, 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 enablingisCoreLibraryDesugaringEnabledinbuild.gradle.kts. - Google Services Plugin failed: Make sure that
google-services.jsonis insideandroid/app/and not in the root folderandroid/. A single level difference in the path is enough for the plugin not to find it.
How does the complete flow work?
| Phase | Technology | Key Action |
|---|---|---|
| 1. Broadcast | Laravel + Kreait | Sending the message to the "all" topic using the FCM v1 API. |
| 2. Gateway | Firebase Cloud Messaging | Authentication via Google Admin SDK (JSON) and routing to the device. |
| 3. Reception | Flutter (Android) | Topic subscription and local rendering based on the app state (foreground/background). |
Step 1: Configuration in Firebase Console
- Create Project: Go to the Firebase Console and create a new project.
- Generate Laravel Credentials: Project settings > Service accounts > "Generate new private key". Download the JSON and save it as
firebase_credentials.jsonin the root of your Laravel project. - Register Android App: Add an Android app with the package name of your Flutter project (e.g.,
com.desarrollolibre.blog). Downloadgoogle-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-servicesto the plugins. - User Subscription: In your
main.dartor 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
});
}