Guía Definitiva: Notificaciones Push con Firebase (FCM v1) en Laravel y Flutter

- Andrés Cruz - EN In english

Guía Definitiva: Notificaciones Push con Firebase (FCM v1) en Laravel y Flutter

Implementar notificaciones push hoy en día significa trabajar con el protocolo FCM v1 de Firebase, que reemplazó al antiguo sistema de Server Keys en junio de 2024. En esta guía, veremos cómo configurar el flujo completo: desde el backend en Laravel hasta la recepción y visualización de notificaciones en una app Flutter para Android.

1. Configuración del Proyecto en Firebase

Antes de escribir una sola línea de código, hay que preparar el entorno en la consola de Firebase. Estos pasos son la base de todo lo demás.

  • Crear el Proyecto: Ve a Firebase Console y crea un nuevo proyecto. Si ya tienes uno existente, puedes usarlo directamente.
  • Añadir la App Android: Registra tu app usando el applicationId definido en tu build.gradle (ej. com.desarrollo.libre). Este identificador debe coincidir exactamente con el de tu proyecto Flutter.
  • Descargar google-services.json: Este archivo contiene las credenciales de tu app y actúa como el "puente" entre tu código Android y los servicios de Firebase.

    ⚠️ IMPORTANTE: El archivo debe ir en la ruta android/app/google-services.json. Si lo colocas en la raíz de la carpeta android/, el plugin de Google Services fallará silenciosamente durante la compilación.
  • Credenciales del Servidor: Ve a "Configuración del proyecto" → "Cuentas de servicio" y genera una nueva clave privada en formato JSON. Este archivo es el que usará Laravel para autenticarse con la API de FCM v1 y poder enviar mensajes.

2. Implementación en Laravel

En el backend, utilizaremos la librería kreait/laravel-firebase para interactuar con la API de FCM v1. Además de instalar el paquete vía Composer, es recomendable registrar el ServiceProvider manualmente en bootstrap/providers.php para evitar problemas con el auto-discovery en ciertos entornos de producción:

bootstrap/providers.php

return [
    App\Providers\AppServiceProvider::class,
    // ... otros providers
    Kreait\Laravel\Firebase\ServiceProvider::class, // Registro manual obligatorio
];

Con el provider registrado, podemos crear el servicio que encapsula la lógica de envío. Usamos inyección a través del contrato Messaging::class, lo que hace que el código sea más fácil de testear y mantener:

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) // Enviamos a un tema global
            ->withNotification($notification)
            ->withData($data);

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

El parámetro $topic funciona como un canal de difusión: todos los dispositivos suscritos a ese tema recibirán el mensaje. El valor por defecto 'all' sirve para notificaciones globales, pero puedes crear temas más específicos si necesitas segmentar tu audiencia (por ejemplo, 'premium' o 'es_users').

3. Configuración de Android en Flutter

La parte de Android requiere un par de ajustes críticos: uno para habilitar características modernas de Java en versiones antiguas del sistema operativo, y otro para que las notificaciones en segundo plano se vean correctamente.

Habilitar Desugaring (Core Library Desugaring)

Si usas flutter_local_notifications (v21+), necesitas habilitar el soporte para las APIs de Java 8+. Esto es obligatorio porque algunas de sus dependencias internas usan clases que no existían en versiones antiguas de Android.

android/app/build.gradle.kts

android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true // CRÍTICO
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
}

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

¿Qué hace "isCoreLibraryDesugaringEnabled"?

Esta opción actúa como un "traductor" en tiempo de compilación. Permite que paquetes que usan APIs modernas de Java funcionen sin errores en versiones antiguas de Android (como Android 5.0 o 6.0). Sin esto, el error CheckAarMetadataWorkAction interrumpe la construcción porque el compilador detecta una incompatibilidad entre las nuevas librerías y la versión mínima de Android que soporta tu app.

Configuración del Manifiesto

Añadimos el permiso de notificaciones (requerido desde Android 13) y definimos el ID del canal por defecto. Este ID es el que usará FCM cuando la app esté en segundo plano o cerrada para mostrar la notificación automáticamente, sin pasar por el código Dart.

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. Implementación en Flutter (Dart)

Creamos el servicio FirebaseApi para manejar tres responsabilidades: la inicialización del SDK, la suscripción al tema y la escucha de mensajes cuando la app está en primer plano. Las notificaciones en segundo plano las gestiona FCM directamente usando la configuración del manifiesto.

lib/api/firebase_api.dart

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

  // Canal de alta importancia para Android (debe coincidir con el del Manifiesto)
  final _androidChannel = const AndroidNotificationChannel(
    'high_importance_channel',
    'Notificaciones Importantes',
    importance: Importance.max,
  );

  Future<void> initNotifications() async {
    // 1. Solicitar permisos al sistema (Android 13+)
    await _firebaseMessaging.requestPermission();

    // 2. Suscribirse al tema 'all' (debe coincidir con el topic del backend)
    await _firebaseMessaging.subscribeToTopic('all');

    // 3. Inicializar flutter_local_notifications para manejar el foreground
    await initLocalNotifications();

    // 4. Listener para mensajes cuando la app está abierta
    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',
          ),
        ),
      );
    });
  }
}

Vale la pena entender por qué necesitamos flutter_local_notifications para el foreground: cuando la app está activa, FCM entrega el mensaje en silencio a través del listener onMessage, pero no muestra ningún banner automáticamente. Es entonces cuando tomamos el control y disparamos la notificación visual usando el plugin local.

Puedes llamar a initNotifications() desde main.dart, pero una práctica más recomendable es mostrar primero un "soft prompt" para pedirle permiso al usuario antes de activar el diálogo nativo del sistema. Esto mejora significativamente la tasa de aceptación, ya que el usuario entiende el contexto antes de ver la ventana de permisos de Android:

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(),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

El widget comprueba dos condiciones antes de mostrarse: que el usuario no tenga ya el permiso nativo activado y que no haya descartado el prompt anteriormente. Si ambas se cumplen, aparece el banner. De lo contrario, retorna un SizedBox.shrink() invisible, sin ocupar espacio en el árbol de widgets.

Lecciones Aprendidas y Solución de Errores

  • ¿Por qué no llegaban las notificaciones? El backend enviaba correctamente al tema all, pero la app nunca se suscribía a él desde el cliente. La solución es simple pero fácil de olvidar: subscribeToTopic('all') debe ejecutarse durante la inicialización.
  • Error CheckAarMetadata: Este error aparece durante la compilación cuando hay una incompatibilidad entre las versiones de las librerías y el minSdkVersion de tu app. Se resuelve habilitando isCoreLibraryDesugaringEnabled en el build.gradle.kts.
  • Google Services Plugin failed: Asegúrate de que el google-services.json esté dentro de android/app/ y no en la carpeta raíz android/. La diferencia de un nivel en la ruta es suficiente para que el plugin no lo encuentre.

¿Cómo funciona el flujo completo?

FaseTecnologíaAcción Clave
1. EmisiónLaravel + KreaitEnvío del mensaje al topic "all" mediante la API de FCM v1.
2. Puerta de enlaceFirebase Cloud MessagingAutenticación vía Google Admin SDK (JSON) y enrutamiento al dispositivo.
3. RecepciónFlutter (Android)Suscripción al tema y renderizado local según el estado de la app (foreground/background).

Paso 1: Configuración en Firebase Console

  1. Crear Proyecto: Ve a Firebase Console y crea un nuevo proyecto.
  2. Generar Credenciales Laravel: Configuración del proyecto > Cuentas de servicio > "Generar nueva clave privada". Descarga el JSON y guárdalo como firebase_credentials.json en la raíz de tu proyecto Laravel.
  3. Registrar App Android: Añade una app de Android con el package name de tu proyecto Flutter (ej: com.desarrollolibre.blog). Descarga el google-services.json.

Paso 2: Implementación en Laravel

Para que el sistema sea estable, usamos el wrapper kreait/laravel-firebase y registramos el ServiceProvider manualmente para evitar fallos de auto-discovery.

1. Registro en bootstrap/providers.php:

return [
    App\Providers\AppServiceProvider::class,
    // ... otros providers
    Kreait\Laravel\Firebase\ServiceProvider::class, // Registro manual obligatorio
];

2. Lógica del Servicio (FirebaseService.php):

use Kreait\Firebase\Contract\Messaging;

public function sendPushNotification($title, $body, $topic = 'all') {
    $messaging = app(Messaging::class); // Inyección vía Contrato
    
    $message = CloudMessage::new()
        ->withTopic($topic) // Sintaxis correcta v7+
        ->withNotification(Notification::create($title, $body));

    $messaging->send($message);
}

Paso 3: Configuración en Flutter (Android Studio)

Configura tu entorno móvil para reaccionar a las señales del servidor:

  • google-services.json: Pégalo dentro de android/app/.
  • build.gradle (Project): Añade google-services en los plugins.
  • Suscripción de Usuario: En tu main.dart o API Service, suscríbete al tema que el backend está emitiendo.
// En lib/api/firebase_api.dart
Future<void> initNotifications() async {
    await _firebaseMessaging.requestPermission();
    
    // El secreto: Suscribirse al mismo tema que envía Laravel
    await _firebaseMessaging.subscribeToTopic('all'); 
    
    // Escuchar mensajes en primer plano
    FirebaseMessaging.onMessage.listen((message) {
        showLocalNotification(message); // Usando flutter_local_notifications
    });
}

Configura notificaciones push con Firebase FCM v1, Laravel y Flutter para Android. Incluye solución a errores comunes como CheckAarMetadata y 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:

Acepto recibir anuncios de interes sobre este Blog.