DEV Community

Cover image for Real-Time Private Channel Notifications in Vue 3 with Laravel Echo and Pusher
Oghenetega Adiri
Oghenetega Adiri

Posted on

Real-Time Private Channel Notifications in Vue 3 with Laravel Echo and Pusher

In today's fast-paced web applications, real-time notifications have become essential for providing users with immediate updates and enhancing their overall experience. In this comprehensive guide, I'll walk you through creating a real-time notification system using Vue 3's Composition API, Laravel Echo, and Pusher.

The implementation we'll cover includes a complete notification system with features like:

  • Connection management with automatic reconnection
  • Persistent subscriptions that survive page refreshes
  • Notification state management
  • Elegant UI components for displaying notifications
  • Browser notifications support

Table of Contents

Prerequisites

Before we start building, you'll need:

  • A Vue 3 project using Composition API
  • A Laravel backend with Laravel Reverb and Laravel Echo Server set up
  • Basic knowledge of WebSockets and event broadcasting

Once you have all of the above, we can start digging in 😎👌🔥

Understanding the Architecture

Our notification system follows a layered architecture:

  • Laravel Echo Client: Core WebSocket connection manager
  • Notification Service: Business logic for handling notifications
  • Notification Store: State management for notifications
  • UI Components: Visual presentation of notifications

Each layer has a specific responsibility, making the system maintainable and extensible.

Setting Up Laravel Echo

Let's start by creating a dedicated Echo service that manages the WebSocket connections. This service will handle connection state persistence, automatic reconnection, and channel subscriptions.

// echo.js import Echo from 'laravel-echo'; import Pusher from 'pusher-js'; // LocalStorage keys for persistence const ECHO_CONNECTION_ID = 'user_echo_connection_id'; const ECHO_CONNECTION_STATE = 'user_echo_connection_state'; const ECHO_SUBSCRIBED_CHANNELS = 'user_echo_subscribed_channels'; const ECHO_CHANNEL_HANDLERS = 'user_echo_channel_handlers'; const ECHO_LAST_ACTIVE = 'user_echo_last_active'; // Singleton instance let echoInstance = null; const createEcho = () => { const token = localStorage.getItem('token'); const lastActive = localStorage.getItem(ECHO_LAST_ACTIVE); const timeElapsed = lastActive ? Date.now() - parseInt(lastActive) : null; // Update last active time localStorage.setItem(ECHO_LAST_ACTIVE, Date.now().toString()); // Reuse existing connection if active if (echoInstance && echoInstance.connector && echoInstance.connector.pusher && echoInstance.connector.pusher.connection.state === 'connected') { console.log('Reusing existing Echo instance with active connection'); return echoInstance; } // Create new Echo instance echoInstance = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_REVERB_KEY, wsHost: import.meta.env.VITE_REVERB_HOST, wsPort: import.meta.env.VITE_REVERB_PORT, forceTLS: import.meta.env.MODE === 'production' ? import.meta.env.VITE_REVERB_SCHEME : false, disableStats: true, enabledTransports: ['ws', 'wss'], cluster: 'mt1', //we are using a dummy cluster activityTimeout: 30000, pongTimeout: 15000, enableLogging: true, // Required for private channels authEndpoint: 'https://api.example.com/broadcasting/auth', auth: { headers: { 'X-APP-AUTH': import.meta.env.VITE_AUTH_KEY, 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' } } }); // Connection event handlers echoInstance.connector.pusher.connection.bind('connected', () => { const socketId = echoInstance.connector.pusher.connection.socket_id; localStorage.setItem(ECHO_CONNECTION_ID, socketId); localStorage.setItem(ECHO_CONNECTION_STATE, 'connected'); localStorage.setItem(ECHO_LAST_ACTIVE, Date.now().toString()); // Automatic resubscription to saved channels resubscribeToSavedChannels(); }); // Add more connection event handlers... echoInstance.connector.pusher.connection.bind('disconnected', () => { localStorage.setItem(ECHO_CONNECTION_STATE, 'disconnected'); }); echoInstance.connector.pusher.connection.bind('error', (err) => { console.error('Pusher connection error:', err); }); // Track subscriptions for persistence echoInstance.saveSubscription = (channelName, eventName, handlerInfo) => { try { // Get current subscriptions const subscribedChannels = JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]'); if (!subscribedChannels.includes(channelName)) { subscribedChannels.push(channelName); localStorage.setItem(ECHO_SUBSCRIBED_CHANNELS, JSON.stringify(subscribedChannels)); } // Save event handlers const handlers = JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}'); if (!handlers[channelName]) { handlers[channelName] = []; } // Check for duplicate handlers const existingHandler = handlers[channelName].find(h => h.event === eventName); if (!existingHandler) { handlers[channelName].push({ event: eventName, type: handlerInfo.type }); localStorage.setItem(ECHO_CHANNEL_HANDLERS, JSON.stringify(handlers)); } } catch (error) { console.error('Error saving subscription:', error); } }; // Handle visibility changes to reconnect when tab becomes active document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { const pusher = echoInstance?.connector?.pusher; if (pusher && pusher.connection.state !== 'connected') { console.log('Page visible, reconnecting...'); pusher.connect(); } } }); return echoInstance; }; // Function to resubscribe to saved channels function resubscribeToSavedChannels() { try { const subscribedChannels = JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]'); const handlers = JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}'); if (subscribedChannels.length === 0) return; // Trigger resubscription via global handler if (window.userResubscribeToChannels) { window.userResubscribeToChannels(subscribedChannels, handlers); } } catch (error) { console.error('Error during resubscription:', error); } } // Clear Echo state on logout createEcho.clearEchoState = () => { localStorage.removeItem(ECHO_SUBSCRIBED_CHANNELS); localStorage.removeItem(ECHO_CHANNEL_HANDLERS); localStorage.removeItem(ECHO_CONNECTION_ID); localStorage.removeItem(ECHO_CONNECTION_STATE); localStorage.removeItem(ECHO_LAST_ACTIVE); // Disconnect instance if exists if (echoInstance && echoInstance.connector) { try { echoInstance.connector.pusher.disconnect(); } catch (e) { console.error('Error disconnecting Echo:', e); } echoInstance = null; } }; // For debugging window.userEchoDebug = { getInstance: () => echoInstance, getConnectionState: () => echoInstance?.connector?.pusher?.connection?.state || 'no-instance', clearState: createEcho.clearEchoState, getStoredState: () => ({ connectionId: localStorage.getItem(ECHO_CONNECTION_ID), connectionState: localStorage.getItem(ECHO_CONNECTION_STATE), channels: JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]'), handlers: JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}'), lastActive: localStorage.getItem(ECHO_LAST_ACTIVE) }) }; export default createEcho; 
Enter fullscreen mode Exit fullscreen mode

Our createEcho.js module provides several key features:

  • Singleton pattern - Only one Echo instance is created
  • Connection state persistence - Saves connection state in localStorage
  • Channel subscription persistence - Tracks subscribed channels
  • Automatic reconnection - Reconnects when the page becomes visible
  • Debugging tools - Global object for connection debugging

Creating a Notification Service

Next, let's create a notification service that uses our Echo instance to subscribe to notification channels and format incoming notifications:

// notificationService.js import { ref } from "vue"; import createEcho from "./echo"; import { useNotificationStore } from "@/stores/notificationStore"; // Initialization key const USER_NOTIFICATION_INIT_KEY = 'user_notification_service_initialized'; export const useNotificationService = async (providedUserStore = null) => { // Create a fresh Echo instance with the current token const echo = createEcho(); // Get user store (passed or dynamically imported) const userStore = providedUserStore || (await getUserStore()); // Initialize notification store const notificationStore = useNotificationStore(); // Notification type constants const notificationTypes = { TRIP_CREATED: "trip_created", WALLET_FUNDED: "wallet_funded", // Add more notification types... }; // Format notification based on type and data const formatNotification = (type, data) => { const currentTime = new Date(); const formattedTime = currentTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", }); const formattedDate = currentTime.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", }); let message = ""; let title = ""; // Format notification based on type switch (type) { case notificationTypes.TRIP_CREATED: title = "Trip Created"; message = data.message; break; case notificationTypes.WALLET_FUNDED: title = "Wallet Funded"; message = data.message; break; // Add more notification types... default: title = "Notification"; message = "You have a new notification"; } return { id: Date.now(), type, title, message, time: formattedTime, date: formattedDate, fullTime: `${formattedDate} . ${formattedTime}`, data, status: "unread", }; }; // Add notification to store and show browser notification const addNotification = (notification) => { // Add to global notification store notificationStore.addNotification(notification); // Show browser notification if allowed if (Notification.permission === "granted") { try { new Notification(notification.title, { body: notification.message, icon: "/favicon.png", }); } catch (error) { console.warn("Error displaying browser notification:", error); } } }; // Helper to get user store dynamically (avoids circular dependency) const getUserStore = async () => { const { useUserStore } = await import("@/stores/userStore"); return useUserStore(); }; // Subscribe to a private channel with error handling const subscribeToPrivateChannel = (channelName, eventName, handler, handlerType) => { try { // Check if channel already exists const existingChannels = echo.connector.pusher.channels.channels; if (existingChannels[channelName]) { existingChannels[channelName].bind(eventName, handler); return existingChannels[channelName]; } // Create new subscription const channel = echo.private(channelName); // Debug subscription status channel.subscribed(() => { console.log(`Successfully subscribed to private channel: ${channelName}`); // Track this subscription with its handler info echo.saveSubscription(channelName, eventName, { type: handlerType }); }); // Error handler channel.error((err) => { console.error(`Channel subscription error for ${channelName}:`, err); }); // Bind the event handler channel.listen(eventName, (data) => { console.log(`Received event ${eventName} on channel ${channelName}:`, data); handler(data); }); return channel; } catch (error) { console.error(`Exception when subscribing to private channel ${channelName}:`, error); return null; } }; // Global resubscription handler for saved channels window.userResubscribeToChannels = (channels, handlers) => { if (!userStore.user?.id) { console.error("User ID not available for resubscription"); return; } const userId = userStore.user.id; channels.forEach(channelName => { const channelHandlers = handlers[channelName] || []; // Resubscribe based on saved handlers channelHandlers.forEach(handlerInfo => { // Create appropriate event handlers based on channel and event if (channelName === `trip.created.${userId}` && handlerInfo.event === 'TripCreated') { subscribeToPrivateChannel( channelName, handlerInfo.event, (data) => { const notification = formatNotification(notificationTypes.TRIP_CREATED, data); addNotification(notification); }, 'TRIP_CREATED' ); } // Add more channel/event combinations... }); // If no handlers found, create based on channel name pattern if (!channelHandlers.length) { if (channelName === `trip.created.${userId}`) { subscribeToPrivateChannel( channelName, "TripCreated", (data) => { const notification = formatNotification(notificationTypes.TRIP_CREATED, data); addNotification(notification); }, 'TRIP_CREATED' ); } // Add more channel patterns... } }); }; // Initialize Echo and subscribe to channels const initializeEcho = async () => { if (!userStore.user?.id) { console.error("User ID not available for notification subscription"); return; } const userId = userStore.user.id; console.log(`Initializing notifications for user ID: ${userId}`); // Mark service as initialized localStorage.setItem(USER_NOTIFICATION_INIT_KEY, 'true'); // Subscribe to user-specific channels subscribeToPrivateChannel( `trip.created.${userId}`, "TripCreated", (data) => { const notification = formatNotification(notificationTypes.TRIP_CREATED, data); addNotification(notification); }, 'TRIP_CREATED' ); // Add more channel subscriptions... // Subscribe to trip-specific channels if needed subscribeToTripChannels(); }; // Subscribe to trip-specific channels const subscribeToTripChannels = async () => { // Get user's active trips const userTrips = userStore.activeTrips || []; if (userTrips.length === 0) { console.log("No active trips found for subscription"); return; } // Subscribe to channels for each trip userTrips.forEach((trip) => { const tripId = trip; // Add trip-specific subscriptions... subscribeToPrivateChannel( `trip.departure.${tripId}`, "TripDeparture", (data) => { const notification = formatNotification( notificationTypes.TRIP_DEPARTURE, { ...data, departure: trip.departure, destination: trip.destination, } ); addNotification(notification); }, 'TRIP_DEPARTURE' ); // Add more trip-specific channels... }); }; // Request browser notification permission const requestNotificationPermission = async () => { if (!("Notification" in window)) { console.log("This browser does not support desktop notifications"); return false; } if (Notification.permission === "granted") { return true; } if (Notification.permission !== "denied") { const permission = await Notification.requestPermission(); return permission === "granted"; } return false; }; // Cleanup function for logout const cleanup = () => { try { console.log("Cleaning up user notification subscriptions..."); // Use the clearEchoState method to remove localStorage entries createEcho.clearEchoState(); // Remove global resubscribe handler delete window.userResubscribeToChannels; // Remove the notification initialization flag localStorage.removeItem(USER_NOTIFICATION_INIT_KEY); // Clear the notification store notificationStore.deleteAllNotifications(); console.log('User notification service cleaned up'); } catch (error) { console.error("Error cleaning up user notifications:", error); } }; // Return service methods and state return { // Get notifications from store get notifications() { return { value: notificationStore.notifications }; }, get unreadCount() { return { value: notificationStore.unreadCount }; }, initializeEcho, markAllAsRead: notificationStore.markAllAsRead, markAsRead: notificationStore.markAsRead, subscribeToTripChannels, requestNotificationPermission, cleanup, }; }; export default useNotificationService; 
Enter fullscreen mode Exit fullscreen mode

The notification service:

  • Creates an Echo instance for WebSocket connection
  • Provides formatting for different notification types
  • Manages channel subscriptions and resubscriptions
  • Handles browser notification permissions
  • Offers cleanup functionality for logout

Building the Notification Store

To manage notification state across components, we need a store. In a Vue 3 project, you can use Pinia or a similar state management solution:

// notificationStore.js import { defineStore } from 'pinia'; export const useNotificationStore = defineStore('notification', { state: () => ({ notifications: [], }), getters: { // Get unread count unreadCount: (state) => state.notifications.filter(n => n.status === 'unread').length, // Get notifications by status getNotificationsByStatus: (state) => (status) => { return state.notifications.filter(notification => notification.status === status); }, }, actions: { // Add a new notification addNotification(notification) { this.notifications.unshift(notification); }, // Mark notification as read markAsRead(notificationId) { const notification = this.notifications.find(n => n.id === notificationId); if (notification) { notification.status = 'read'; } }, // Mark all notifications as read markAllAsRead() { this.notifications.forEach(notification => { notification.status = 'read'; }); }, // Delete a notification deleteNotification(notificationId) { const index = this.notifications.findIndex(n => n.id === notificationId); if (index !== -1) { this.notifications.splice(index, 1); } }, // Delete all notifications deleteAllNotifications() { this.notifications = []; }, }, }); 
Enter fullscreen mode Exit fullscreen mode

UI Components for Notifications

Now let's create the UI components for displaying notifications:

Notification Dropdown Component

<!-- NotificationDropDown.vue --> <script setup> import { ref, onMounted, computed, inject } from "vue"; import { useRouter } from "vue-router"; import NotificationModal from "./NotificationModal.vue"; import useNotificationService from "@/services/userNotificationService/notificationService"; import { useUserStore } from "@/stores/userStore"; import { useNotificationStore } from "@/stores/notificationStore"; // inject notificationModalRef  const notificationModalRef = inject('notificationModalRef'); const router = useRouter(); const userStore = useUserStore(); const notificationStore = useNotificationStore(); const notificationService = ref(null); const emit = defineEmits(['close']); const tabOptions = ref([ { label: "All", value: "all", }, { label: "Unread", value: "unread", }, ]); const currentTab = ref("all"); const selectedNotification = ref(null); const isModalOpen = ref(false); onMounted(async () => { // Initialize notification service if needed if (!notificationService.value) { notificationService.value = await useNotificationService(userStore); } }); // Filter notifications based on selected tab const filteredNotifications = computed(() => { if (currentTab.value === "all") { return notificationStore.notifications; } else { return notificationStore.getNotificationsByStatus('unread'); } }); // Get counts from store const unreadCount = computed(() => notificationStore.unreadCount); const notificationCount = computed(() => notificationStore.notifications.length); const handleTabChange = (tab) => { currentTab.value = tab; }; const openNotificationDetails = (notification) => { // Mark as read notificationStore.markAsRead(notification.id); selectedNotification.value = notification; isModalOpen.value = true; }; const closeModal = () => { isModalOpen.value = false; selectedNotification.value = null; }; const markAllAsRead = () => { if (unreadCount.value > 0) { notificationStore.markAllAsRead(); } }; // Format notification time const formatNotificationTime = (notification) => { if (!notification) return ''; return notification.time; }; // Redirect to settings const redirectToSettings = () => { router.push({ name: "Settings", query: { tab: "notifications" } }); emit('close'); }; </script> <!-- Template section would go here --> 
Enter fullscreen mode Exit fullscreen mode

In the header component, we'll add a notification icon to show unread count and toggle the notification dropdown:

<!-- DashboardHeader.vue (partial) --> <script setup> import { ref, onMounted, onBeforeUnmount, computed, provide } from "vue"; import NotificationDropDown from "./NotificationComponents/NotificationDropDown.vue"; import { useUserStore } from "@/stores/userStore"; import { useNotificationStore } from "@/stores/notificationStore"; import useNotificationService from "@/services/userNotificationService/notificationService"; // Notification modal reference const notificationModalRef = ref(null); // Provide modal reference to child components provide("notificationModalRef", notificationModalRef); const userStore = useUserStore(); const notificationStore = useNotificationStore(); // Notification service and state const USER_NOTIFICATION_INIT_KEY = 'user_notification_service_initialized'; const notificationService = ref(null); const openNotificationDropdown = ref(false); const notificationDropdownRef = ref(null); const notificationIconRef = ref(null); // Get unread count from the store const unreadCount = computed(() => notificationStore.unreadCount); // Toggle notification dropdown const toggleNotificationDropdown = () => { openNotificationDropdown.value = !openNotificationDropdown.value; // Close other dropdowns openProfileDropdown.value = false; }; // Function to close dropdowns when clicking outside const handleClickOutside = (event) => { // Ignore modal close actions if (event.target.closest('[data-modal-action]')) { return; } // Check for notification dropdown if ( openNotificationDropdown.value && !event.target.closest('[data-notification-trigger]') && notificationDropdownRef.value && !notificationDropdownRef.value.contains(event.target) && (!notificationModalRef.value || !notificationModalRef.value.contains(event.target)) ) { openNotificationDropdown.value = false; } }; // Clean up notification service before logout const cleanupNotifications = async () => { try { console.log("Cleaning up notification subscriptions..."); // If we have a notification service instance, use it if (notificationService.value) { await notificationService.value.cleanup(); } // Otherwise, get a new instance to clean up else if (localStorage.getItem(USER_NOTIFICATION_INIT_KEY) === 'true') { const service = await useNotificationService(userStore); await service.cleanup(); } // Remove initialization flag and clear store localStorage.removeItem(USER_NOTIFICATION_INIT_KEY); notificationStore.deleteAllNotifications(); console.log("Notification service cleaned up"); } catch (error) { console.error("Error cleaning up notifications:", error); } }; // Provide cleanup function to child components provide("cleanupNotifications", cleanupNotifications); // Logout handler const logout = async () => { // Clean up notification service before logout await cleanupNotifications(); // Perform logout API call http() .get("auth/logout") .then((response) => { if (response.status === 200 || response.status === 201) { authStore.deleteToken(); userStore.deleteUser(); router.replace({ name: "signin" }); } }); }; // Set up event listeners onMounted(async () => { document.addEventListener("click", handleClickOutside); // Initialize notification service if needed if (!notificationService.value) { notificationService.value = await useNotificationService(userStore); if (localStorage.getItem(USER_NOTIFICATION_INIT_KEY) !== 'true') { await notificationService.value.initializeEcho(); await notificationService.value.requestNotificationPermission(); } } }); // Clean up event listeners onBeforeUnmount(() => { document.removeEventListener("click", handleClickOutside); }); </script> <template> <header> <!-- Logo and other header content --> <div class="header-controls"> <!-- Notification icon --> <div ref="notificationIconRef" class="notification-icon" @click="toggleNotificationDropdown" data-notification-trigger > <!-- Unread indicator --> <div v-if="unreadCount > 0" class="unread-badge"> <p>{{ unreadCount }}</p> </div> <img src="@/assets/Icons/notifications.svg" alt="notifications" /> </div> <!-- Other header controls --> </div> <!-- Notification dropdown container --> <div ref="notificationDropdownRef"> <NotificationDropDown v-if="openNotificationDropdown" @close="toggleNotificationDropdown" /> </div> </header> </template> 
Enter fullscreen mode Exit fullscreen mode

Testing and Debugging

For effective debugging of real-time WebSocket connections, our Echo service includes a global debug object that can be accessed from the browser console:

// Access the debug object in your browser console window.userEchoDebug.getConnectionState() // Check connection state window.userEchoDebug.getStoredState() // View stored channel subscriptions window.userEchoDebug.getInstance() // Access the Echo instance 
Enter fullscreen mode Exit fullscreen mode

When testing your notification system, check these common issues:

  • Authentication Errors: Ensure that your Echo instance has the correct authentication token for private channels
  • Channel Naming: Verify the channel names match between backend and frontend
  • Event Names: Make sure event names are consistent in Laravel and Vue
  • Connection Status: Check connection state if notifications aren't being received
  • Browser Compatibility: Test browser notifications on different platforms

Best Practices

Based on our implementation, here are some best practices for real-time notification systems:

  • Singleton Pattern: Use a singleton pattern for your Echo instance to avoid multiple connections
  • Connection Persistence: Store connection state in localStorage to improve reconnection
  • Channel Subscription Persistence: Save subscribed channels to restore them after page refresh
  • Visibility Change Handling: Reconnect when the page becomes visible
  • Clean Separation of Concerns:
    • Echo client handles connection
    • Notification service manages business logic
    • Store manages state
    • UI components handle presentation
  • Proper Cleanup: Clean up resources and disconnect when users log out
  • Request Notification Permissions Early: Ask for browser notification permission during initialization
  • Error Handling: Add robust error handling for all WebSocket operations
  • Format Notifications Consistently: Use a consistent structure for all notification types
  • Provide Fallbacks: Use in-app notifications as a fallback when browser notifications are denied

Conclusion

Building a real-time notification system with Vue 3, Laravel Echo, and Pusher provides a seamless user experience for your web application. The architecture presented in this article offers a robust solution that:

  • Maintains connection state across page refreshes
  • Automatically reconnects when necessary
  • Provides persistent channel subscriptions
  • Offers both in-app and browser notifications
  • Includes proper cleanup on logout

By following this implementation, you've created a complete notification system that's scalable and maintainable. The modular approach makes it easy to add new notification types or modify existing ones without affecting the core functionality.

Remember to secure your WebSocket connections properly and handle authentication errors gracefully. With these considerations in mind, your notification system will provide a reliable and engaging experience for your users.

What improvements or additional features would you add to this notification system? Let me know in the comments!

Top comments (0)