Skip to main content
This is an excellent use case for Server-Sent Events (SSE). It’s much more efficient than polling, as it keeps a single, long-lived HTTP connection open for the server to push updates to the client. Here is a complete step-by-step implementation for your Laravel 10 and Vue 2 application.

The Flow

  1. A user visits a page with the notification component.
  2. The Vue component opens an SSE connection to a specific Laravel route (e.g., /notifications/stream).
  3. The Laravel controller keeps this connection open.
  4. When a new Notification is created in your MongoDB collection, the NotificationObserver fires.
  5. The observer’s created method will write the new notification data to the cache with a user-specific key.
  6. The SseController, in its long-running loop, detects the new cache entry, sends it down the stream to the browser, and then deletes the cache entry.
  7. Vue’s EventSource listener receives the data and updates the UI in real-time.
  8. The “Refresh” button will call a separate, standard API endpoint to fetch all notifications, just like your old polling mechanism.

Step 1: Backend - The Model and Observer

First, let’s set up the model and the observer that will react to new database entries.

1a. Notification Model

I’ll assume you are using the jenssegers/laravel-mongodb package. Your model might look something like this. app/Models/Notification.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Jenssegers\Mongodb\Eloquent\Model;

class Notification extends Model
{
    use HasFactory;

    // Use 'mongodb' connection if you have it configured in config/database.php
    protected $connection = 'mongodb';

    // The collection associated with the model.
    protected $collection = 'notifications';

    protected $fillable = [
        'user_id', // The user who should receive the notification
        'message',
        'read_at',
        'link',
    ];

    protected $casts = [
        'read_at' => 'datetime',
        'user_id' => 'integer', // Cast if your user IDs are integers
    ];

    /**
     * Get the user that owns the notification.
     */
    public function user()
    {
        // Assuming your User model is a standard Eloquent model
        return $this->belongsTo(User::class);
    }
}

1b. Create the Observer

This is the core of the trigger mechanism. Generate the observer using Artisan:
php artisan make:observer NotificationObserver --model=Notification
Now, let’s implement the created method. It will write the new notification to the cache. The cache acts as a fast, temporary message queue between the web request that created the notification and the SSE process. app/Observers/NotificationObserver.php
<?php

namespace App\Observers;

use App\Models\Notification;
use Illuminate\Support\Facades\Cache;

class NotificationObserver
{
    /**
     * Handle the Notification "created" event.
     *
     * We will cache the new notification with a unique key for the user.
     * The SSE controller will be watching for this cache key.
     */
    public function created(Notification $notification): void
    {
        // We use a user-specific key.
        // The 'pull' method in the controller will retrieve and delete this.
        // We add a unique suffix to handle multiple notifications created in quick succession.
        $key = 'user.' . $notification->user_id . '.new_notification.' . uniqid();

        // Cache for 10 minutes, just in case the SSE process is down.
        Cache::put($key, $notification->toJson(), 600);
    }
}
Note: We use uniqid() to ensure that if two notifications are created for the same user before the SSE loop runs, both are stored in the cache under different keys.

1c. Register the Observer

You must register the observer in a service provider. The best place is EventServiceProvider. app/Providers/EventServiceProvider.php
<?php

namespace App\Providers;

use App\Models\Notification;
use App\Observers\NotificationObserver;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    // ...

    /**
     * Register any events for your application.
     */
    public function boot(): void
    {
        Notification::observe(NotificationObserver::class);
    }

    // ...
}

Step 2: Backend - The SSE Controller and Route

This controller will handle the persistent connection.

2a. Create the Controller

php artisan make:controller SseController

2b. Implement the SSE Logic

We’ll use Laravel’s StreamedResponse to send data without closing the connection. app/Http/Controllers/SseController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\StreamedResponse;

class SseController extends Controller
{
    public function stream()
    {
        $response = new StreamedResponse(function() {
            $user = Auth::user();

            while (true) {
                // 1. Check if the connection is aborted by the client
                if (connection_aborted()) {
                    break;
                }

                // 2. Check for new notification keys in the cache for this user
                $cachePrefix = 'user.' . $user->id . '.new_notification.';
                $keys = Cache::getStore()->getPrefix() . $cachePrefix . '*';

                // In Redis, you'd use Cache::keys('...'), but for file/db cache we scan
                // Note: For high-traffic sites, use Redis for this pattern.
                // For simplicity, this example will work with standard file/db cache.
                $notificationKeys = collect(\Illuminate\Support\Facades\DB::table('cache')
                    ->where('key', 'like', $keys.'%')
                    ->pluck('key')
                    ->map(fn($key) => str_replace(Cache::getStore()->getPrefix(), '', $key))
                );

                foreach ($notificationKeys as $key) {
                    // Use Cache::pull to get the item and delete it atomically
                    $notificationJson = Cache::pull($key);

                    if ($notificationJson) {
                        // 3. Format the data as an SSE message
                        echo "event: new-notification\n"; // Event name
                        echo "id: " . uniqid() . "\n";   // Event ID
                        echo "data: " . $notificationJson . "\n\n"; // The JSON data

                        // 4. Flush the output buffer to send the data to the client
                        ob_flush();
                        flush();
                    }
                }

                // 5. Sleep for a short duration to avoid high CPU usage
                sleep(2);
            }
        });

        // Set the headers for SSE
        $response->headers->set('Content-Type', 'text/event-stream');
        $response->headers->set('X-Accel-Buffering', 'no'); // Nginx-specific, important
        $response->headers->set('Cache-Control', 'no-cache');
        $response->headers->set('Connection', 'keep-alive');

        return $response;
    }
}
Important: The cache scanning part is not very performant with the file or database cache drivers. For production, you should strongly consider using redis: CACHE_DRIVER=redis in your .env file. With Redis, you could use Cache::keys($cachePrefix . '*') which is much more efficient.

2c. Define the Route

This route must be protected by authentication middleware. routes/web.php
use App\Http\Controllers\SseController;
use App\Http\Controllers\NotificationController; // We'll create this next

// ... other routes

Route::middleware(['auth'])->group(function () {
    // The SSE endpoint
    Route::get('/notifications/stream', [SseController::class, 'stream'])->name('notifications.stream');

    // The manual-refresh endpoint (your old polling endpoint)
    Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
});

Step 3: Backend - Manual Refresh Endpoint

This is the standard endpoint that the “Refresh” button will use.

3a. Create the Controller

php artisan make:controller NotificationController

3b. Implement the Index Method

This method simply fetches all notifications for the current user. app/Http/Controllers/NotificationController.php
<?php

namespace App\Http\Controllers;

use App\Models\Notification;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class NotificationController extends Controller
{
    /**
     * Fetch all notifications for the authenticated user.
     * This is used for the initial load and manual refresh.
     */
    public function index()
    {
        $notifications = Notification::where('user_id', Auth::id())
            ->latest() // Order by most recent
            ->take(10)   // Limit the number
            ->get();

        return response()->json($notifications);
    }
}

Step 4: Frontend - The Vue 2 Component

Now let’s create the Vue component that consumes the SSE stream and provides the manual refresh button. resources/js/components/NotificationBell.vue
<template>
  <div class="notification-container">
    <button @click="toggleDropdown" class="bell-button">
      <i class="fa fa-bell"></i> <!-- Assuming you use FontAwesome -->
      <span v-if="notificationCount > 0" class="notification-badge">{{ notificationCount }}</span>
    </button>
    <div v-if="isOpen" class="notification-dropdown">
      <div class="dropdown-header">
        <span>Notifications</span>
        <button @click.prevent="refreshNotifications" class="refresh-button">Refresh</button>
      </div>
      <ul>
        <li v-if="notifications.length === 0" class="no-notifications">
          No new notifications
        </li>
        <li v-for="notification in notifications" :key="notification._id">
          {{ notification.message }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
import axios from 'axios';

export default {
  name: 'NotificationBell',
  data() {
    return {
      notifications: [],
      isOpen: false,
      eventSource: null,
    };
  },
  computed: {
    notificationCount() {
      // You might want to filter for unread notifications here
      return this.notifications.length;
    }
  },
  methods: {
    toggleDropdown() {
      this.isOpen = !this.isOpen;
    },

    /**
     * Manual refresh method using Axios (your old polling logic).
     */
    refreshNotifications() {
      console.log('Manually fetching notifications...');
      axios.get('/notifications')
        .then(response => {
          this.notifications = response.data;
        })
        .catch(error => {
          console.error("Error fetching notifications:", error);
        });
    },

    /**
     * Sets up the Server-Sent Events connection.
     */
    connectToSse() {
      // Make sure we don't have an existing connection
      if (this.eventSource) {
        this.eventSource.close();
      }

      console.log('Connecting to SSE stream...');
      this.eventSource = new EventSource('/notifications/stream');

      this.eventSource.onopen = () => {
        console.log('SSE Connection opened.');
      };

      // Listen for our custom 'new-notification' event
      this.eventSource.addEventListener('new-notification', (event) => {
        console.log('New notification received via SSE:', event.data);
        const newNotification = JSON.parse(event.data);

        // Add the new notification to the top of the list
        this.notifications.unshift(newNotification);
      });

      this.eventSource.onerror = (error) => {
        console.error('SSE Error:', error);
        // The browser will automatically try to reconnect, but you might want
        // to close it after several failed attempts.
        this.eventSource.close();
      };
    }
  },
  mounted() {
    // 1. Fetch initial notifications on component mount
    this.refreshNotifications();

    // 2. Connect to the SSE stream for real-time updates
    this.connectToSse();
  },
  beforeDestroy() {
    // VERY IMPORTANT: Close the connection when the component is destroyed
    // to prevent memory leaks and orphaned server processes.
    if (this.eventSource) {
      console.log('Closing SSE connection.');
      this.eventSource.close();
    }
  }
};
</script>

<style scoped>
/* Add some basic styling for the component */
.notification-container {
  position: relative;
  display: inline-block;
}
.bell-button {
  position: relative;
  background: none;
  border: none;
  font-size: 24px;
  cursor: pointer;
}
.notification-badge {
  position: absolute;
  top: -5px;
  right: -5px;
  background-color: red;
  color: white;
  border-radius: 50%;
  padding: 2px 6px;
  font-size: 12px;
}
.notification-dropdown {
  position: absolute;
  right: 0;
  top: 100%;
  width: 300px;
  background: white;
  border: 1px solid #ccc;
  box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  z-index: 1000;
}
.dropdown-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 12px;
  border-bottom: 1px solid #eee;
}
.refresh-button {
  font-size: 12px;
}
ul {
  list-style: none;
  padding: 0;
  margin: 0;
  max-height: 400px;
  overflow-y: auto;
}
li {
  padding: 10px 15px;
  border-bottom: 1px solid #eee;
}
li:last-child {
  border-bottom: none;
}
.no-notifications {
  color: #888;
  text-align: center;
}
</style>
You can now use the <notification-bell></notification-bell> component anywhere in your Vue application. You’ve successfully replaced polling with a real-time SSE push system while retaining the ability to manually refresh.