Static dashboards are so 2020. In modern applications, users expect updates to happen instantly—without reloading the page.
With the release of Laravel Reverb, handling WebSockets became first-party native. But connecting the backend plumbing to a polished UI is still a challenge for many.
In this extensive guide, we are going to build a production-grade Real-Time Notification Center. We will cover everything: from installing Reverb to updating a Beartropy badge counter in the UI without a page refresh.
🔌 Phase 1: Installation & Configuration
Before writing any code, we need to activate the WebSocket server. Laravel 11+ makes this incredibly easy with a single artisan command.
1. Install Broadcasting:
Run the following command in your terminal:
1php artisan install:broadcasting
When prompted, select Reverb. This command will automatically:
- Install the
laravel/reverb package.
- Publish the Reverb configuration.
- Install and configure Laravel Echo and Pusher-JS in your
package.json.
- Update your
.env file.
2. Verify Environment Variables:
Check your .env file to ensure the WebSocket credentials are set. They should look something like this:
1BROADCAST_CONNECTION=reverb
2
3REVERB_APP_ID=my-app-id
4REVERB_APP_KEY=my-app-key
5REVERB_APP_SECRET=my-app-secret
6REVERB_HOST="localhost"
7REVERB_PORT=8080
8REVERB_SCHEME="http"
9
10VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
11VITE_REVERB_HOST="${REVERB_HOST}"
12VITE_REVERB_PORT="${REVERB_PORT}"
13VITE_REVERB_SCHEME="${REVERB_SCHEME}"
3. Start the Server:
Open a new terminal tab and fire up the Reverb server:
1php artisan reverb:start
Now your application is ready to transmit data in real-time!
📡 Phase 2: The Architecture
We need three layers to make the notification system work:
- The Trigger: A backend event (e.g.,
OrderShipped) that implements ShouldBroadcast.
- The Channel: A private channel unique to the user (
App.Models.User.{id}).
- The Listener: A Livewire component listening via Laravel Echo to update the UI.
📣 Phase 3: The Broadcastable Event
Let's start by creating an event that carries the notification payload.
1namespace App\Events;
2
3use App\Models\Order;
4use Illuminate\Broadcasting\InteractsWithSockets;
5use Illuminate\Broadcasting\PrivateChannel;
6use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
7use Illuminate\Queue\SerializesModels;
8
9class OrderShipped implements ShouldBroadcast
10{
11 use InteractsWithSockets, SerializesModels;
12
13 public function __construct(public Order $order) {}
14
15 public function broadcastOn(): array
16 {
17 // Broadcast only to the user who owns the order
18 return [
19 new PrivateChannel('App.Models.User.' . $this->order->user_id),
20 ];
21 }
22
23 public function broadcastWith(): array
24 {
25 return [
26 'title' => 'Order #' . $this->order->id . ' Shipped!',
27 'body' => 'Your package is on its way.',
28 'link' => route('orders.show', $this->order),
29 'timestamp' => now()->toIso8601String(),
30 ];
31 }
32}
🔔 Phase 4: The Livewire Component (NotificationBell)
This component will live in your navigation bar. It needs to fetch unread notifications on load AND listen for new ones coming from Reverb.
1namespace App\Livewire;
2
3use Livewire\Attributes\On;
4use Livewire\Component;
5use Illuminate\Support\Facades\Auth;
6
7class NotificationBell extends Component
8{
9 public $unreadCount = 0;
10 public $notifications = [];
11
12 public function mount()
13 {
14 $this->refreshNotifications();
15 }
16
17 public function refreshNotifications()
18 {
19 $user = Auth::user();
20 $this->unreadCount = $user->unreadNotifications()->count();
21 $this->notifications = $user->unreadNotifications()->take(5)->get();
22 }
23
24 // 👂 The Magic: Listening to Reverb via Livewire Attributes
25 #[On('echo-private:App.Models.User.{userId},OrderShipped')]
26 public function onOrderShipped($event)
27 {
28 // 1. Play a subtle sound (Optional but cool)
29 $this->dispatch('play-notification-sound');
30
31 // 2. Show a Toast immediately
32 $this->dispatch('notify', [
33 'type' => 'info',
34 'message' => $event['title']
35 ]);
36
37 // 3. Refresh the list to show the new item in the dropdown
38 $this->refreshNotifications();
39 }
40
41 // Computed property for the userId to use in the listener attribute
42 public function getUserIdProperty()
43 {
44 return Auth::id();
45 }
46
47 public function markAsRead($notificationId)
48 {
49 Auth::user()->notifications()->where('id', $notificationId)->first()?->markAsRead();
50 $this->refreshNotifications();
51 }
52
53 public function render()
54 {
55 return view('livewire.notification-bell');
56 }
57}
🎨 Phase 5: The UI (Beartropy Integration)
Here is where we make it look good. We will use the x-bt-dropdown component with its dedicated sub-components (.header, .item, .separator) to structure the menu cleanly.
1<div
2 x-data="{ animating: false }"
3 @notify.window="animating = true; setTimeout(() => animating = false, 1000)"
4 class="relative"
5>
6 <x-bt-dropdown align="right" width="w-80">
7
8
9 <x-slot:trigger>
10 <button class="relative p-2 text-gray-400 hover:text-gray-500 transition">
11 <span class="sr-only">View notifications</span>
12
13
14 <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
15 <path stroke-linecap="round" stroke-linejoin="round" d="M14.857..." />
16 </svg>
17
18
19 @if($unreadCount > 0)
20 <span
21 class="absolute top-0 right-0 block h-4 w-4 transform rounded-full ring-2 ring-white bg-red-500 text-xs text-white text-center leading-4"
22 :class="{ 'animate-bounce': animating }"
23 >
24 {{ $unreadCount }}
25 </span>
26 @endif
27 </button>
28 </x-slot:trigger>
29
30
31 <x-bt-dropdown.header>
32 Notifications
33 </x-bt-dropdown.header>
34
35
36 @forelse($notifications as $notification)
37 <x-bt-dropdown.item
38 wire:click="markAsRead('{{ $notification->id }}')"
39 class="gap-3 items-start"
40 >
41
42 <div class="flex gap-3">
43 <div class="mt-1 shrink-0">
44 <x-bt-badge dot color="info" />
45 </div>
46 <div>
47 <p class="text-sm font-medium text-gray-900">
48 {{ $notification->data['title'] }}
49 </p>
50 <p class="text-xs text-gray-500 mt-0.5">
51 {{ $notification->data['body'] }}
52 </p>
53 <p class="text-[10px] text-gray-400 mt-1">
54 {{ $notification->created_at->diffForHumans() }}
55 </p>
56 </div>
57 </div>
58 </x-bt-dropdown.item>
59 @empty
60 <div class="px-4 py-6 text-center text-sm text-gray-500">
61 No new notifications.
62 </div>
63 @endforelse
64
65
66 @if($unreadCount > 0)
67 <x-bt-dropdown.separator />
68
69 <x-bt-dropdown.item
70 wire:click="markAllRead"
71 class="justify-center font-medium text-indigo-600"
72 >
73 Mark all as read
74 </x-bt-dropdown.item>
75 @endif
76
77 </x-bt-dropdown>
78</div>
🔊 Phase 6: The Finishing Touches (Audio)
Remember the $this->dispatch('play-notification-sound') in our PHP component? Let's hook that up in our global layout using a simple Alpine component.
1
2<div x-data="{
3 play() {
4 new Audio('/sounds/notification.mp3').play().catch(e => console.log('Audio blocked'));
5 }
6}" @play-notification-sound.window="play()"></div>
Summary
We just built a fully reactive system where:
- Reverb is running and listening.
- The Backend broadcasts events instantly.
- Livewire catches them via the
#[On] attribute.
- Beartropy UI updates the badge and shows the dropdown content.
This is the power of the TALL stack: Seamless Real-Time UX without the complexity of a separate Node.js frontend.