Building Real-time Features with Laravel Reverb
Prerequisites
- Laravel 11+
- Node.js for the frontend bits
- A basic understanding of Laravel events
- At least one mass-produced energy drink within arm’s reach
- The emotional resilience of someone who has debugged CORS errors at 2am
What We’re Building
Right then. We’re adding real-time features to a Laravel app using Reverb, Laravel’s official WebSocket server. Live notifications, chat, presence channels, the whole lot. No third-party services, no monthly invoices from Pusher that make your eye twitch, no sending your data to someone else’s servers and hoping for the best.
If you’ve ever bolted Pusher onto a Laravel app and thought “this is fine but why am I paying for something my server could do itself,” this is your moment.

The Approach
- Install and configure Reverb
- Set up broadcasting
- Create event classes
- Build frontend listeners
- Add presence channels
Nothing too wild. Lets get into it.
Step 1: Install Reverb
composer require laravel/reverb
php artisan reverb:install
This creates config/reverb.php, updates your .env with Reverb variables, and adds broadcasting configuration. One command and you’re most of the way there. Laravel really does spoil us sometimes.
Update your .env:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Step 2: Start the Server
php artisan reverb:start
If you want to see what’s actually happening under the hood (and you absolutely should the first time), pass the debug flag:
php artisan reverb:start --debug
You’ll get a lovely stream of connection events scrolling past. Very satisfying. Like watching logs on a fireplace, but nerdier.
Step 3: Create a Broadcast Event
This is where the magic starts. We create an event that implements ShouldBroadcast and Laravel handles the rest.
// app/Events/MessageSent.php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Message $message) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('chat.'.$this->message->conversation_id),
];
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'content' => $this->message->content,
'user' => [
'id' => $this->message->user->id,
'name' => $this->message->user->name,
],
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}
The broadcastWith method lets you control exactly what data gets sent over the wire. No accidentally leaking user emails or password hashes to the frontend. You’re welcome, future you.
Now dispatch it from your controller:
// In your controller
public function store(Request $request, Conversation $conversation)
{
$message = $conversation->messages()->create([
'user_id' => auth()->id(),
'content' => $request->content,
]);
broadcast(new MessageSent($message))->toOthers();
return response()->json($message);
}
That ->toOthers() is doing important work. It means the person who sent the message doesnt get their own message echoed back at them. Small detail, massive difference in user experience.
Step 4: Configure Channel Authorisation
Private channels need authorisation. Otherwise, what’s the point of them being private?
// routes/channels.php
use App\Models\Conversation;
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
return Conversation::find($conversationId)?->users->contains($user);
});
Clean and simple. If the user is part of the conversation, they get access. If not, they can jog on.

Step 5: Frontend Setup
Install Echo and the Pusher JS library (Reverb uses the Pusher protocol, which is actually quite clever):
npm install laravel-echo pusher-js
Configure Echo:
// 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 ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
Yes, you’re importing pusher-js even though you’re not using Pusher. Its just the protocol. Try not to think about it too hard.
Now listen for events:
// resources/js/chat.js
Echo.private(`chat.${conversationId}`)
.listen('MessageSent', (e) => {
console.log('New message:', e);
messages.value.push(e);
});
And just like that, you’ve got real-time messaging. Messages appear the instant they’re sent. No polling, no refreshing, no “have you tried turning it off and on again.”
Step 6: Presence Channels
This is the fun bit. Presence channels let you track who’s online and what they’re doing. Perfect for “user is typing” indicators that make your chat feel properly alive.
// app/Events/UserTyping.php
class UserTyping implements ShouldBroadcast
{
public function __construct(public User $user, public Conversation $conversation) {}
public function broadcastOn(): array
{
return [
new PresenceChannel('chat.'.$this->conversation->id),
];
}
}
Update the channel authorisation to return user data:
// routes/channels.php
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
if (Conversation::find($conversationId)?->users->contains($user)) {
return ['id' => $user->id, 'name' => $user->name];
}
});
And on the frontend, Echo.join gives you the full presence experience:
Echo.join(`chat.${conversationId}`)
.here((users) => {
onlineUsers.value = users;
})
.joining((user) => {
onlineUsers.value.push(user);
})
.leaving((user) => {
onlineUsers.value = onlineUsers.value.filter(u => u.id !== user.id);
})
.listen('UserTyping', (e) => {
showTypingIndicator(e.user);
});
.here() fires when you first join and gives you everyone already in the channel. .joining() and .leaving() do exactly what you’d expect. It’s beautifully intuitive.
Step 7: Notifications
Real-time notifications for authenticated users. Because nobody wants to refresh their page to find out someone followed them.
// app/Notifications/NewFollower.php
class NewFollower extends Notification implements ShouldBroadcast
{
public function via($notifiable): array
{
return ['database', 'broadcast'];
}
public function toBroadcast($notifiable): BroadcastMessage
{
return new BroadcastMessage([
'message' => "{$this->follower->name} started following you",
'follower' => $this->follower->only('id', 'name', 'avatar'),
]);
}
}
Listen on the user’s private notification channel:
Echo.private(`App.Models.User.${userId}`)
.notification((notification) => {
showToast(notification.message);
});
That’s it. Real-time notifications with about six lines of frontend code. Lovely stuff.

Step 8: Production Deployment
For production, you’ll want Supervisor keeping Reverb alive. Because WebSocket servers that crash at 3am and dont restart themselves are not the vibe.
[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/reverb.log
And stick Nginx in front of it to handle the WebSocket upgrade:
location /app {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
The Result
What you end up with:
- Real-time messaging that actually feels instant
- Presence detection so users know who’s online
- Live notifications without polling
- Zero third-party dependencies for WebSockets
- Full control over your infrastructure

What I’d Do Differently
Add connection error handling and reconnection logic on the frontend from the start. WebSocket connections drop. They just do. Users shouldnt have to manually refresh the page to recover. A bit of exponential backoff and a “reconnecting…” toast goes a long way toward making the experience feel solid rather than fragile.
Reverb makes real-time Laravel feel native. No more wrestling with Pusher pricing or complex Redis configurations. Just WebSockets that work, running on your own kit.