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

- Andrés Cruz - EN In english

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
    });
}
Video thumbnail

Diseño e Implementación de un Sistema de Notificaciones ESCALABLE con Laravel

Muy de la mano del módulo anterior y que también puedes emplear en conjunto con la implementación anterior. Te mostraré cómo implementar un sistema sencillo de notificaciones que, aunque inicialmente pueda parecer básico, ofrece una gran flexibilidad y conceptos arquitectónicos clave. Además, analizararemos la importancia de comprender a fondo el código que generamos o que obtenemos mediante asistencia de inteligencia artificial.

Inicialmente, utilicé un prompt para generar la estructura general de las notificaciones. La IA sugirió una arquitectura basada en tópicos (o temas), común en integraciones con servicios como Firebase. Esta estructura resultó muy valiosa para construir un sistema multicanal en lugar de uno limitado a un único flujo de trabajo.

El objetivo final es permitir el envío de notificaciones desde un panel administrativo local hacia el usuario. Para ver el código fuente y la guía completa de integración con Firebase, puedes consultar la documentación que he preparado en la plataforma.

Estructura de la Base de Datos

El sistema se apoya principalmente en dos tablas dentro de la base de datos:

  • site_notifications: Almacena las notificaciones globales enviadas desde el panel administrativo. Contiene campos principales como el título (title), el cuerpo (body) y el tópico (topic).
  • site_notification_user: Funciona como tabla pivote para gestionar el estado de lectura de cada notificación por usuario.

Para controlar qué notificaciones ha visto un usuario, registramos el identificador del usuario (user_id) y el de la notificación (notification_id). Se utiliza un método updateOrCreate para garantizar que no se dupliquen registros al actualizar el estado de lectura.

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

Optimizando la Limpieza de Datos

Mantener todos los registros de lectura de manera indefinida puede saturar la base de datos a medida que aumentan los usuarios y los años de uso. Como los usuarios raramente revisan notificaciones antiguas, es conveniente definir una política de limpieza.

Para solucionar esto, implementamos el método cleanOldRecords en el modelo pivote, el cual elimina automáticamente el historial de notificaciones con más de cuatro meses de antigüedad:

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

Al consultar las notificaciones en el API, también aplicamos un filtro para que la fecha mínima coincida con este período de cuatro meses. Esto evita inconsistencias entre las notificaciones registradas y las mostradas en el panel del usuario.

Endpoints y Consumo desde el Frontend

El consumo del sistema desde la interfaz en Vue.js se maneja mediante tres endpoints principales definidos en el controlador de la API:

  1. index: Devuelve las notificaciones no leídas y el contador que se muestra en el icono flotante del panel.
  2. all: Retorna todas las notificaciones paginadas dentro del rango de tiempo permitido (cuatro meses).
  3. markAsRead: Recibe el identificador de una notificación y actualiza la fecha de lectura (read_at) en la tabla pivote.
<?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');
    }
}

La Importancia de los Tópicos (Topics)

La clase encargada de definir los tópicos es una pieza fundamental en esta arquitectura. Permite dirigir mensajes a audiencias generales o a usuarios específicos sin acoplar la notificación a un solo módulo.

class GetTopic
{
    /**
     * Obtiene los temas del usuario autenticado
     *
     * @param User
     */
    public function getUserTopics(User $user): array
    {
        $topics = ['all', 'news', 'offers', "user:{$user->id}"];
        return $topics;
    }
}    

Por ejemplo, podemos definir un tópico general como 'all' para comunicados globales, o utilizar una convención basada en el identificador del usuario como "user:{id}" para mensajes privados:

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

Gracias a este enfoque, cuando se realiza una consulta para un usuario en particular, el backend únicamente recupera las notificaciones de los tópicos a los que dicho usuario está suscrito.

Integración Eventual y Arquitectura de Software

Un caso de uso práctico para este sistema ocurre cuando recibimos un mensaje a través del módulo de contacto. Si el usuario está autenticado, podemos abrir un canal de comunicación directo reutilizando la entidad de comentarios (commentables) e inmediatamente emitir una notificación privada al administrador:

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, // ID del administrador
        authorName: $contact->name ?? $user->name ?? 'Usuario',
        message: $contact->message,
        actionValue: "/contact/{$contact->id}"
    );
}

Este tipo de integración demuestra la importancia de entender la arquitectura del software. Las aplicaciones son entidades vivas que evolucionan con el tiempo; reutilizar modelos existentes (como relaciones polimórficas de comentarios o el sistema de tópicos de notificaciones) evita la duplicación innecesaria de código y mantiene la mantenibilidad del proyecto a largo plazo.

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.