Beartropy Logo

Real-Time Magic: Building a Notification Center with Laravel Reverb and Beartropy

Bring your app to life. A comprehensive guide to installing Laravel Reverb and building a real-time notification center with Beartropy UI.

Guides 07 Jan, 2026 Beartropy Team

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:

  1. The Trigger: A backend event (e.g., OrderShipped) that implements ShouldBroadcast.
  2. The Channel: A private channel unique to the user (App.Models.User.{id}).
  3. 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:

  1. Reverb is running and listening.
  2. The Backend broadcasts events instantly.
  3. Livewire catches them via the #[On] attribute.
  4. 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.

Tags

#laravel #reverb #websockets #real-time #livewire #beartropy

Comments

Leave a comment

0

No comments yet. Be the first to share your thoughts!

Share this post