Laravel & Vue: Real-time Notifications using Reverb, Redis, and TDD - 05

Laravel & Vue: Real-time Notifications using Reverb, Redis, and TDD - 05

Chapter 5: Integrating Reverb into the Frontend with Vue: Receiving Notifications

Objective

In this chapter, we will configure the Vue frontend to connect to Reverb channels and display real-time notifications, following the TDD (Test-Driven Development) approach. Specifically:

  1. Install and configure Laravel Echo with Reverb in Vue.
  2. Create Vue components to handle real-time notifications.
  3. Subscribe to specific notification channels in the frontend.
  4. Receive real-time notification events and update the user interface.

Introduction

So far, we have built a robust backend system to generate and transmit notifications using Reverb, Redis, and Laravel events. Now it's time to connect the frontend so that users can receive these notifications in real-time without needing to refresh the page.

Laravel Echo is the official library Laravel provides for easily integrating WebSockets into JavaScript applications. With the advent of Reverb, Echo has been optimized to work natively with this new technology, providing a smoother development experience and better performance.

In this chapter, we will use Vue 3 with the Composition API to create reactive components that listen for Reverb events and update the interface in real-time.

Initial Frontend Setup

1. Install Frontend Dependencies

First, let's install the necessary dependencies for Vue and Laravel Echo:

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

2. Configure Vite for Vue

Modify the vite.config.js file to include the Vue plugin:

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. Configure Laravel Echo in the Frontend

Create a configuration file for Laravel Echo in 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. Update Environment Variables for Frontend

Add the following variables to your .env file:

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

TDD Cycle: Creating Frontend Tests

1. Configure Vitest for Frontend Tests

Install the necessary dependencies for testing:

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

Create the vitest.config.js configuration file:

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. Create Tests for the Notifications Component

Create the tests directory and an initial test:

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

Modify 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 for global fetch to avoid real requests during tests
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,
            },
        });

        // Simulate notification reception
        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. Run the Initial Test (Red)

Add the test script to package.json:

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

Run the test:

sail npm run test:run

Expected errors:

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

vitest error

TDD Cycle: Implementing the Notifications Component

1. Create the NotificationManager Component

Create the components directory and the component:

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

Modify resources/js/components/NotificationManager.vue:

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

    <div class="notification-panel" v-if="showPanel">
      <div class="notification-header">
        <h3>Notifications</h3>
        <button @click="markAllAsRead" class="mark-all-read">
          Mark all as read
        </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 notifications
      </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,
  },
});

// Reactive states
const notifications = ref([]);
const showPanel = ref(false);
const userChannel = ref(null);
const adminChannel = ref(null);

// Computed states
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); // Show only the last 50 notifications
});

// Methods
const subscribeToChannels = () => {
  // Subscribe to user channel
  userChannel.value = window.Echo.private(`user-notifications.${props.userId}`)
    .listen('order.status.changed', (e) => {
      addNotification(e);
    });

  // Subscribe to admin channel if user is 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: `Order #${notificationData.order_id}: ${notificationData.old_status} → ${notificationData.new_status}`,
    timestamp: notificationData.timestamp,
    read: false,
    ...notificationData,
  };

  notifications.value.unshift(notification);

  // Show browser notification if available
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification('New Notification', {
      body: notification.message,
      icon: '/favicon.ico',
    });
  }
};

const markAsRead = (notification) => {
  notification.read = true;
  // Here you could send a request to the backend to mark as read in 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 'just now';
  if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
  if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
  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 {
    // Use absolute URL or check if we are in a test environment
    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);
  }
};

// Expose methods for 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. Create API Routes for Notification Management

Create the controller for the notifications API:

sail artisan make:controller Api/NotificationController

Modify 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)
    {
        // Get notifications from 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;

        // Remove from unread list in Redis
        Redis::srem("notifications:unread:{$userId}", $notificationId);

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

    public function markAllAsRead($userId)
    {
        // Clear all unread notifications
        Redis::del("notifications:unread:{$userId}");

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

Installing API Routes for Notifications

Now let's install the necessary API routes to manage notifications. To do this, we will execute the following command:

sail artisan install:api

Add the routes in 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. Configure the Main Vue Application

Modify 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. Create a Test View

Create a blade file to test the component:

touch resources/views/dashboard.blade.php

Modify 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 - Notifications</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>User: {{ 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">
                            Real-Time Notification System
                        </h2>
                        <p class="text-gray-600 mb-8">
                            Notifications will automatically appear when order statuses change.
                        </p>

                        <button
                            onclick="simulateOrderChange()"
                            class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
                        >
                            Simulate Order Change
                        </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. Create Route to Simulate Changes

Add a route in routes/web.php for the test view:

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

And in routes/api.php add a route to simulate changes:

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

    // Simulate status change
    $order->update(['delivery_status' => 'in_transit']);

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

6. Run the Tests (Green)

Run the frontend tests:

sail npm run test:run

vitest passed

And the backend tests:

sail artisan test

unit tests passed

Start the Complete System

1. Start Reverb

In one terminal:

sail artisan reverb:start --debug

2. Start the Queue Process

In another terminal:

sail artisan queue:work

3. Start the Frontend Development Server

In another terminal:

sail npm run dev

4. Test the Application

  1. Visit http://localhost/dashboard
  2. Click "Simulate Order Change"
  3. Observe how the notification appears in real-time

Common Error Debugging

1. Notifications Do Not Appear in the Frontend

If notifications do not appear:

  • Verify that Reverb is running: sail artisan reverb:start --debug
  • Confirm that VITE_* environment variables are correctly configured
  • Check the browser console for WebSocket connection errors

2. Authorization Errors in Channels

If you receive 403 errors:

  • Ensure that authentication routes are configured in channels.php
  • Verify that the CSRF token is being sent correctly

3. WebSocket Connection Issues

If there are connection issues:

  • Verify that Reverb is running on the correct port
  • Confirm that there are no firewall or proxy conflicts
  • Check Reverb logs for connection errors

4. Notifications Are Not Stored in Redis

If notifications are not stored:

  • Check that Redis is running: sail redis-cli ping
  • Verify that the listener is correctly processing events
  • Check queue logs: sail artisan queue:work --verbose

Optimizations and Best Practices

1. Limit the Number of Notifications

To avoid performance issues, limit the number of notifications displayed:

const displayNotifications = computed(() => {
  return notifications.value
    .slice()
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, 50); // Only the last 50
});

2. Implement Debouncing for Multiple Notifications

If many notifications can arrive quickly, implement debouncing:

import { debounce } from 'lodash-es';

const debouncedAddNotification = debounce(addNotification, 100);

3. Persist State in LocalStorage

To keep notifications between page reloads:

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

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

4. Implement Retry Logic for Failed Connections

To automatically reconnect if the connection is lost:

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

Conclusion

In this chapter, we have successfully implemented the integration of Reverb into the frontend with Vue, following the TDD cycle:

  1. We configured Laravel Echo with Reverb in Vue.
  2. We created a reactive component to handle real-time notifications.
  3. We implemented subscription to specific notification channels.
  4. We developed the user interface to display and manage notifications.
  5. We added functionalities such as marking as read and browser notifications.

In the next chapter, we will improve the user interface by creating more advanced components and optimizing the user experience with animations and transitions. 🚀

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...

Subscribe for Updates

Provide your email to get email notifications about new posts or updates.