Laravel & Vue: Notificaciones en Tiempo Real usando Reverb, Redis y TDD - 05

Capítulo 5: Integración de Reverb en el Frontend con Vue: Recepción de Notificaciones

Objetivo

En este capítulo, configuraremos el frontend en Vue para conectarse a los canales de Reverb y mostrar las notificaciones en tiempo real, siguiendo el enfoque TDD (Test-Driven Development). Específicamente:

  1. Instalar y configurar Laravel Echo con Reverb en Vue.
  2. Crear componentes Vue para manejar notificaciones en tiempo real.
  3. Suscribirse a canales específicos de notificaciones en el frontend.
  4. Recibir eventos de notificación en tiempo real y actualizar la interfaz de usuario.

Introducción

Hasta ahora, hemos construido un sistema robusto en el backend para generar y transmitir notificaciones usando Reverb, Redis y eventos de Laravel. Ahora es momento de conectar el frontend para que los usuarios puedan recibir estas notificaciones en tiempo real sin necesidad de recargar la página.

Laravel Echo es la librería oficial que Laravel proporciona para integrar fácilmente WebSockets en aplicaciones JavaScript. Con la llegada de Reverb, Echo se ha optimizado para trabajar de manera nativa con esta nueva tecnología, proporcionando una experiencia de desarrollo más fluida y un mejor rendimiento.

En este capítulo, utilizaremos Vue 3 con Composition API para crear componentes reactivos que escuchen los eventos de Reverb y actualicen la interfaz en tiempo real.

Configuración Inicial del Frontend

1. Instalar Dependencias de Frontend

Primero, vamos a instalar las dependencias necesarias para Vue y Laravel Echo:

sail npm install vue@latest @vitejs/plugin-vue laravel-echo pusher-js --save-dev

2. Configurar Vite para Vue

Modifica el archivo vite.config.js para incluir el plugin de Vue:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        vue(),
    ],
    server: {
        host: '0.0.0.0',
        port: 5173,
        hmr: {
            host: 'localhost',
        },
    },
});

3. Configurar Laravel Echo en el Frontend

Crea un archivo de configuración para Laravel Echo en resources/js/echo.js:

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    wssPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    auth: {
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
        },
    },
});

4. Actualizar Variables de Entorno para Frontend

Añade las siguientes variables a tu archivo .env:

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Ciclo TDD: Creación de Pruebas para el Frontend

1. Configurar Vitest para Pruebas Frontend

Instala las dependencias necesarias para testing:

sail npm install --save-dev vitest @vue/test-utils jsdom

Crea el archivo de configuración vitest.config.js:

import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [vue()],
    test: {
        environment: 'jsdom',
        globals: true,
    },
    resolve: {
        alias: {
            '@': resolve(__dirname, 'resources/js'),
        },
    },
});

2. Crear Pruebas para el Componente de Notificaciones

Crea el directorio de pruebas y un test inicial:

mkdir -p resources/js/tests
touch resources/js/tests/NotificationManager.test.js

Modifica resources/js/tests/NotificationManager.test.js:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import NotificationManager from '@/components/NotificationManager.vue';

// Mock Laravel Echo
const mockEcho = {
    private: vi.fn().mockReturnThis(),
    listen: vi.fn().mockReturnThis(),
    notification: vi.fn().mockReturnThis(),
};

global.Echo = mockEcho;

// Mock para fetch global para evitar peticiones reales durante las pruebas
global.fetch = vi.fn(() =>
    Promise.resolve({
        json: () => Promise.resolve({ notifications: [], unread_count: 0 })
    })
);

describe('NotificationManager', () => {
    beforeEach(() => {
        vi.clearAllMocks();
    });

    it('should mount successfully', () => {
        const wrapper = mount(NotificationManager, {
            props: {
                userId: 1,
                isAdmin: false,
            },
        });

        expect(wrapper.exists()).toBe(true);
    });

    it('should subscribe to user notification channel on mount', () => {
        mount(NotificationManager, {
            props: {
                userId: 1,
                isAdmin: false,
            },
        });

        expect(mockEcho.private).toHaveBeenCalledWith('user-notifications.1');
        expect(mockEcho.listen).toHaveBeenCalledWith('order.status.changed', expect.any(Function));
    });

    it('should subscribe to admin notification channel when user is admin', () => {
        mount(NotificationManager, {
            props: {
                userId: 1,
                isAdmin: true,
            },
        });

        expect(mockEcho.private).toHaveBeenCalledWith('admin-notifications');
    });

    it('should add notification to list when event is received', async () => {
        let eventCallback;
        mockEcho.listen.mockImplementation((event, callback) => {
            eventCallback = callback;
            return mockEcho;
        });

        const wrapper = mount(NotificationManager, {
            props: {
                userId: 1,
                isAdmin: false,
            },
        });

        // Simular recepción de notificación
        const mockNotification = {
            order_id: 123,
            old_status: 'En preparación',
            new_status: 'En tránsito',
            timestamp: 1634567890,
        };

        eventCallback(mockNotification);
        await wrapper.vm.$nextTick();

        expect(wrapper.vm.notifications).toHaveLength(1);
        expect(wrapper.vm.notifications[0]).toMatchObject(mockNotification);
    });
});

3. Ejecutar la Prueba Inicial (Rojo)

Añade el script de test al package.json:

{
    "scripts": {
        "test": "vitest",
        "test:run": "vitest run"
    }
}

Ejecuta el test:

sail npm run test:run

Errores esperados:

  • Error: Failed to resolve import "@/components/NotificationManager.vue" from "resources/js/tests/NotificationManager.test.js". Does the file exist?

vitest error

Ciclo TDD: Implementación del Componente de Notificaciones

1. Crear el Componente NotificationManager

Crea el directorio de componentes y el componente:

mkdir -p resources/js/components
touch resources/js/components/NotificationManager.vue

Modifica resources/js/components/NotificationManager.vue:

<template>
  <div class="notification-manager">
    <!-- Indicador de notificaciones -->
    <div class="notification-badge" v-if="unreadCount > 0">
      <span class="badge">{{ unreadCount }}</span>
    </div>

    <!-- Panel de notificaciones -->
    <div class="notification-panel" v-if="showPanel">
      <div class="notification-header">
        <h3>Notificaciones</h3>
        <button @click="markAllAsRead" class="mark-all-read">
          Marcar todas como leídas
        </button>
      </div>

      <div class="notification-list">
        <div
          v-for="notification in displayNotifications"
          :key="notification.id"
          class="notification-item"
          :class="{ 'unread': !notification.read }"
        >
          <div class="notification-content">
            <p class="notification-message">{{ notification.message }}</p>
            <span class="notification-time">{{ formatTime(notification.timestamp) }}</span>
          </div>
          <button
            v-if="!notification.read"
            @click="markAsRead(notification)"
            class="mark-read-btn"
          >

          </button>
        </div>
      </div>

      <div v-if="displayNotifications.length === 0" class="no-notifications">
        No hay notificaciones
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';

// Props
const props = defineProps({
  userId: {
    type: Number,
    required: true,
  },
  isAdmin: {
    type: Boolean,
    default: false,
  },
});

// Estados reactivos
const notifications = ref([]);
const showPanel = ref(false);
const userChannel = ref(null);
const adminChannel = ref(null);

// Estados computados
const unreadCount = computed(() => {
  return notifications.value.filter(n => !n.read).length;
});

const displayNotifications = computed(() => {
  return notifications.value
    .slice()
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, 50); // Mostrar solo las últimas 50 notificaciones
});

// Métodos
const subscribeToChannels = () => {
  // Suscripción al canal del usuario
  userChannel.value = window.Echo.private(`user-notifications.${props.userId}`)
    .listen('order.status.changed', (e) => {
      addNotification(e);
    });

  // Suscripción al canal de administradores si es admin
  if (props.isAdmin) {
    adminChannel.value = window.Echo.private('admin-notifications')
      .listen('order.status.changed', (e) => {
        addNotification(e);
      });
  }
};

const addNotification = (notificationData) => {
  const notification = {
    id: generateId(),
    message: `Pedido #${notificationData.order_id}: ${notificationData.old_status}${notificationData.new_status}`,
    timestamp: notificationData.timestamp,
    read: false,
    ...notificationData,
  };

  notifications.value.unshift(notification);

  // Mostrar notificación del navegador si está disponible
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification('Nueva notificación', {
      body: notification.message,
      icon: '/favicon.ico',
    });
  }
};

const markAsRead = (notification) => {
  notification.read = true;
  // Aquí podrías enviar una petición al backend para marcar como leída en Redis
  markNotificationAsRead(notification.id);
};

const markAllAsRead = () => {
  notifications.value.forEach(notification => {
    if (!notification.read) {
      notification.read = true;
      markNotificationAsRead(notification.id);
    }
  });
};

const markNotificationAsRead = async (notificationId) => {
  try {
    await fetch('/api/notifications/mark-read', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'),
      },
      body: JSON.stringify({
        notification_id: notificationId,
        user_id: props.userId,
      }),
    });
  } catch (error) {
    console.error('Error marking notification as read:', error);
  }
};

const formatTime = (timestamp) => {
  const date = new Date(timestamp * 1000);
  const now = new Date();
  const diff = now - date;

  if (diff < 60000) return 'hace un momento';
  if (diff < 3600000) return `hace ${Math.floor(diff / 60000)} minutos`;
  if (diff < 86400000) return `hace ${Math.floor(diff / 3600000)} horas`;
  return date.toLocaleDateString();
};

const generateId = () => {
  return Date.now().toString(36) + Math.random().toString(36).substr(2);
};

const togglePanel = () => {
  showPanel.value = !showPanel.value;
};

const requestNotificationPermission = () => {
  if ('Notification' in window && Notification.permission === 'default') {
    Notification.requestPermission();
  }
};

// Lifecycle hooks
onMounted(() => {
  subscribeToChannels();
  requestNotificationPermission();
  loadExistingNotifications();
});

onUnmounted(() => {
  if (userChannel.value) {
    window.Echo.leaveChannel(`user-notifications.${props.userId}`);
  }
  if (adminChannel.value) {
    window.Echo.leaveChannel('admin-notifications');
  }
});

const loadExistingNotifications = async () => {
  try {
    // Usar URL absoluta o comprobar si estamos en un entorno de pruebas
    const baseUrl = typeof window !== 'undefined' && window.location.origin ?
        window.location.origin : 'http://localhost';

    const response = await fetch(`${baseUrl}/api/notifications/${props.userId}`);
    const data = await response.json();
    notifications.value = data.notifications || [];
  } catch (error) {
    console.error('Error loading notifications:', error);
  }
};

// Exponer métodos para testing
defineExpose({
  notifications,
  unreadCount,
  addNotification,
  markAsRead,
  togglePanel,
});
</script>

<style scoped>
.notification-manager {
  position: relative;
  display: inline-block;
}

.notification-badge {
  position: relative;
  cursor: pointer;
}

.badge {
  position: absolute;
  top: -8px;
  right: -8px;
  background-color: #ef4444;
  color: white;
  border-radius: 50%;
  padding: 2px 6px;
  font-size: 12px;
  font-weight: bold;
  min-width: 20px;
  text-align: center;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0% { opacity: 1; }
  50% { opacity: 0.5; }
  100% { opacity: 1; }
}

.notification-panel {
  position: absolute;
  top: 100%;
  right: 0;
  width: 350px;
  max-height: 400px;
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  overflow: hidden;
}

.notification-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid #e5e7eb;
  background-color: #f9fafb;
}

.notification-header h3 {
  margin: 0;
  font-size: 18px;
  font-weight: 600;
  color: #111827;
}

.mark-all-read {
  background: #3b82f6;
  color: white;
  border: none;
  padding: 6px 12px;
  border-radius: 4px;
  font-size: 14px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.mark-all-read:hover {
  background: #2563eb;
}

.notification-list {
  max-height: 300px;
  overflow-y: auto;
}

.notification-item {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  padding: 12px 16px;
  border-bottom: 1px solid #f3f4f6;
  transition: background-color 0.2s;
}

.notification-item:hover {
  background-color: #f9fafb;
}

.notification-item.unread {
  background-color: #fef3f2;
  border-left: 4px solid #ef4444;
}

.notification-content {
  flex: 1;
}

.notification-message {
  margin: 0 0 4px 0;
  font-size: 14px;
  color: #111827;
  line-height: 1.5;
}

.notification-time {
  font-size: 12px;
  color: #6b7280;
}

.mark-read-btn {
  background: #10b981;
  color: white;
  border: none;
  border-radius: 50%;
  width: 24px;
  height: 24px;
  font-size: 14px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background-color 0.2s;
}

.mark-read-btn:hover {
  background: #059669;
}

.no-notifications {
  padding: 32px 16px;
  text-align: center;
  color: #6b7280;
  font-style: italic;
}
</style>

2. Crear Rutas API para Gestión de Notificaciones

Crea el controlador para la API de notificaciones:

sail artisan make:controller Api/NotificationController

Modifica app/Http/Controllers/Api/NotificationController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Notification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

class NotificationController extends Controller
{
    public function index($userId)
    {
        // Obtener notificaciones de Redis
        $redisNotifications = Redis::lrange("notifications:{$userId}", 0, 49);
        $notifications = [];

        foreach ($redisNotifications as $notification) {
            $data = json_decode($notification, true);
            $notifications[] = [
                'id' => $data['id'] ?? uniqid(),
                'message' => $data['message'],
                'timestamp' => $data['timestamp'],
                'read' => !Redis::sismember("notifications:unread:{$userId}", $data['id'] ?? ''),
            ];
        }

        return response()->json([
            'notifications' => $notifications,
            'unread_count' => Redis::scard("notifications:unread:{$userId}"),
        ]);
    }

    public function markAsRead(Request $request)
    {
        $request->validate([
            'notification_id' => 'required|string',
            'user_id' => 'required|integer',
        ]);

        $userId = $request->user_id;
        $notificationId = $request->notification_id;

        // Remover de la lista de no leídas en Redis
        Redis::srem("notifications:unread:{$userId}", $notificationId);

        return response()->json(['success' => true]);
    }

    public function markAllAsRead($userId)
    {
        // Limpiar todas las notificaciones no leídas
        Redis::del("notifications:unread:{$userId}");

        return response()->json(['success' => true]);
    }
}

Instalación de Rutas API para Notificaciones

Ahora vamos a instalar las rutas de API necesarias para gestionar las notificaciones. Para ello, ejecutaremos el siguiente comando:

sail artisan install:api

Añade las rutas en routes/api.php:

use App\Http\Controllers\Api\NotificationController;

Route::get('/notifications/{userId}', [NotificationController::class, 'index']);
Route::post('/notifications/mark-read', [NotificationController::class, 'markAsRead']);
Route::post('/notifications/{userId}/mark-all-read', [NotificationController::class, 'markAllAsRead']);

3. Configurar la Aplicación Principal de Vue

Modifica resources/js/app.js:

import './bootstrap';
import { createApp } from 'vue';
import NotificationManager from './components/NotificationManager.vue';
import './echo';

const app = createApp({});

app.component('NotificationManager', NotificationManager);

app.mount('#app');

4. Crear una Vista de Prueba

Crea un archivo blade para probar el componente:

touch resources/views/dashboard.blade.php

Modifica resources/views/dashboard.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>Dashboard - Notificaciones</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="antialiased bg-gray-100">
    <div id="app">
        <nav class="bg-white shadow">
            <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                <div class="flex justify-between h-16">
                    <div class="flex items-center">
                        <h1 class="text-xl font-semibold">Dashboard</h1>
                    </div>
                    <div class="flex items-center space-x-4">
                        <span>Usuario: {{ auth()->user()->name ?? 'Demo' }}</span>
                        <notification-manager
                            :user-id="{{ auth()->id() ?? 1 }}"
                            :is-admin="{{ auth()->user()->is_admin ?? false ? 'true' : 'false' }}"
                        ></notification-manager>
                    </div>
                </div>
            </div>
        </nav>

        <main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
            <div class="px-4 py-6 sm:px-0">
                <div class="border-4 border-dashed border-gray-200 rounded-lg h-96 flex items-center justify-center">
                    <div class="text-center">
                        <h2 class="text-2xl font-bold text-gray-900 mb-4">
                            Sistema de Notificaciones en Tiempo Real
                        </h2>
                        <p class="text-gray-600 mb-8">
                            Las notificaciones aparecerán automáticamente cuando cambien los estados de las órdenes.
                        </p>

                        <!-- Botón para simular cambio de estado -->
                        <button
                            onclick="simulateOrderChange()"
                            class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
                        >
                            Simular Cambio de Estado
                        </button>
                    </div>
                </div>
            </div>
        </main>
    </div>

    <script>
        function simulateOrderChange() {
            fetch('/api/simulate-order-change', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
                },
            })
            .then(response => response.json())
            .then(data => {
                console.log('Order change simulated:', data);
            })
            .catch(error => {
                console.error('Error:', error);
            });
        }
    </script>
</body>
</html>

5. Crear Ruta para Simular Cambios

Añade una ruta en routes/web.php para la vista de prueba:

Route::get('/dashboard', function () {
    return view('dashboard');
});

Y en routes/api.php añade una ruta para simular cambios:

Route::post('/simulate-order-change', function () {
    $user = \App\Models\User::first() ?? \App\Models\User::factory()->create();
    $order = \App\Models\Order::create([
        'user_id' => $user->id,
        'delivery_status' => 'in_preparation',
    ]);

    // Simular cambio de estado
    $order->update(['delivery_status' => 'in_transit']);

    return response()->json(['success' => true, 'order_id' => $order->id]);
});

6. Ejecutar las Pruebas (Verde)

Ejecuta las pruebas frontend:

sail npm run test:run

Y las pruebas backend:

sail artisan test

unit tests passed

Iniciar el Sistema Completo

1. Inicia Reverb

En una terminal:

sail artisan reverb:start --debug

2. Inicia el Proceso de Colas

En otra terminal:

sail artisan queue:work

3. Inicia el Servidor de Desarrollo Frontend

En otra terminal:

sail npm run dev

4. Prueba la Aplicación

  1. Visita http://localhost/dashboard
  2. Haz clic en "Simular Cambio de Estado"
  3. Observa cómo aparece la notificación en tiempo real

Depuración de Errores Comunes

1. Las Notificaciones No Aparecen en el Frontend

Si las notificaciones no aparecen:

  • Verifica que Reverb esté ejecutándose: sail artisan reverb:start --debug
  • Confirma que las variables de entorno VITE_* estén configuradas correctamente
  • Comprueba la consola del navegador para errores de conexión WebSocket

2. Errores de Autorización en los Canales

Si recibes errores 403:

  • Asegúrate de que las rutas de autenticación estén configuradas en channels.php
  • Verifica que el token CSRF esté siendo enviado correctamente

3. Problemas de Conexión WebSocket

Si hay problemas de conexión:

  • Verifica que Reverb esté ejecutándose en el puerto correcto
  • Confirma que no haya conflictos de firewall o proxy
  • Revisa los logs de Reverb para errores de conexión

4. Notificaciones No Se Almacenan en Redis

Si las notificaciones no se almacenan:

  • Comprueba que Redis esté ejecutándose: sail redis-cli ping
  • Verifica que el listener esté procesando correctamente los eventos
  • Revisa los logs de las colas: sail artisan queue:work --verbose

Optimizaciones y Mejores Prácticas

1. Limitar el Número de Notificaciones

Para evitar problemas de rendimiento, limita el número de notificaciones mostradas:

const displayNotifications = computed(() => {
  return notifications.value
    .slice()
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, 50); // Solo las últimas 50
});

2. Implementar Debouncing para Múltiples Notificaciones

Si pueden llegar muchas notificaciones rápidamente, implementa debouncing:

import { debounce } from 'lodash-es';

const debouncedAddNotification = debounce(addNotification, 100);

3. Persistir Estado en LocalStorage

Para mantener notificaciones entre recargas de página:

const saveToLocalStorage = () => {
  localStorage.setItem('notifications', JSON.stringify(notifications.value));
};

const loadFromLocalStorage = () => {
  const stored = localStorage.getItem('notifications');
  if (stored) {
    notifications.value = JSON.parse(stored);
  }
};

4. Implementar Retry Logic para Conexiones Fallidas

Para reconectarse automáticamente si se pierde la conexión:

const setupConnectionRetry = () => {
  window.Echo.connector.pusher.connection.bind('state_change', (states) => {
    if (states.current === 'disconnected') {
      console.log('Connection lost, attempting to reconnect...');
      setTimeout(() => {
        window.Echo.connector.pusher.connect();
      }, 5000);
    }
  });
};

Conclusión

En este capítulo, hemos implementado con éxito la integración de Reverb en el frontend con Vue, siguiendo el ciclo TDD:

  1. Configuramos Laravel Echo con Reverb en Vue.
  2. Creamos un componente reactivo para manejar notificaciones en tiempo real.
  3. Implementamos la suscripción a canales específicos de notificaciones.
  4. Desarrollamos la interfaz de usuario para mostrar y gestionar notificaciones.
  5. Añadimos funcionalidades como marcar como leído y notificaciones del navegador.

En el próximo capítulo, mejoraremos la interfaz de usuario creando componentes más avanzados y optimizando la experiencia del usuario con animaciones y transiciones. 🚀

En esta serie
Capítulo 1: Introducción y Configuración de Reverb y Redis en el Entorno Laravel
Capítulo 2: Creación de Canales de Notificaciones con Reverb en Laravel
Capítulo 3: Configuración de Redis para Almacenar y Administrar Notificaciones
Capítulo 4: Envío de Notificaciones en Laravel con Eventos y Listeners
Capítulo 5: Integración de Reverb en el Frontend con Vue: Recepción de Notificaciones 👈🏽 Estás aquí

José Rafael Gutierrez

Soy un desarrollador web con más de 14 años de experiencia, especializado en la creación de sistemas a medida. Apasionado por la tecnología, la ciencia, y la lectura, disfruto resolviendo problemas de...

Suscríbete para Actualizaciones

Proporcione su correo electrónico para recibir notificaciones sobre nuevas publicaciones o actualizaciones.