You have a Conversation model, a Message model, and a Livewire page that shows the thread. It works, but users have to refresh to see replies. You want messages to land the instant someone hits send — and you would rather not bolt on Pusher, hand-write a pile of Echo glue, or maintain a separate Node service to do it. This is the guide for that: a real-time chat app built entirely on Livewire 4 and Laravel Reverb, Laravel's own first-party WebSocket server.
By the end you will persist each message to the database, broadcast a MessageSent event on a private channel, and have Livewire append it live with no message-handling JavaScript. Then you will layer on a presence channel for the online-user list, add a typing indicator over client whispers, lock every channel down in routes/channels.php, cover it with a Pest test, and ship it. Every code block is copy-paste runnable on Laravel 12 (the same steps hold on 13). When you are ready to run it at real concurrency, the companion piece on scaling Laravel Reverb in production picks up where this one ends.
Install and configure Laravel Reverb#
Start by installing Laravel's broadcasting stack with Reverb as the driver. A single Artisan command pulls in the Reverb server, wires up the client-side Echo scaffolding, publishes the broadcasting config, and creates the routes/channels.php file you will authorize channels in later. Run it once against a fresh or existing Laravel 12 app.
php artisan install:broadcasting --reverb
That command installs the laravel/reverb Composer package, adds the laravel-echo and pusher-js npm packages, publishes config/reverb.php and config/broadcasting.php, and appends the Reverb keys to your .env; the full option reference lives in the Laravel Reverb documentation. It also drops a preconfigured Echo instance into resources/js/echo.js. Set the broadcast connection and confirm the Reverb block looks like this:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-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}"
The generated resources/js/echo.js already reads those VITE_ variables, so all you have to do is compile assets and boot the two long-running processes chat needs — Vite and Reverb:
npm install && npm run dev
php artisan reverb:start --debug
reverb:start runs the WebSocket server on port 8080; the --debug flag prints every connection and broadcast to the terminal, which is invaluable the first time you wire this up. If you would rather not keep a Reverb process running locally, or you want to avoid the Redis dependency entirely on a single box, the Reverb database driver is a lighter-weight starting point.
Model conversations and messages#
The database is the source of truth for chat — the socket only carries a live copy. Before broadcasting anything, model the data so a conversation has many participants and many messages, and a message belongs to one conversation and one sender. This is the schema every later step builds on, so get the relationships right first.
If you do not already have them, generate the models with migrations and factories in one go:
php artisan make:model Conversation -mf
php artisan make:model Message -mf
Define the schema. A conversation_user pivot tracks who is allowed in each thread — that membership check is exactly what you will authorize the channel against later:
// database/migrations/..._create_conversations_table.php
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->timestamps();
});
Schema::create('conversation_user', function (Blueprint $table) {
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->primary(['conversation_id', 'user_id']);
});
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->timestamps();
});
Wire the relationships on both models. Note the messages() relation eager-loads its sender by default so the message list never fires a query per row when it renders:
// app/Models/Conversation.php
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
// app/Models/Message.php
protected $fillable = ['user_id', 'body'];
public function conversation(): BelongsTo
{
return $this->belongsTo(Conversation::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
Rendering a thread means reading message.user.name for every row, so load the user relation up front. If you have Eloquent strict mode enabled — and you should, per preventing N+1 queries with Eloquent strict mode — a missing eager load throws LazyLoadingViolationException instead of silently firing hundreds of queries. Run php artisan migrate and move on.
Build the Livewire chat component#
With the data modelled, build the Livewire component that renders the thread and sends new messages. This component holds the messages as a plain array in its state, validates the input server-side, and persists each message — it is the piece both the sender and the broadcast listener will feed into. Generate it with Artisan:
php artisan make:livewire ChatRoom
The class loads the existing history on mount, validates and stores new messages, and keeps everything in a $messages array so the view has a single thing to loop over:
// app/Livewire/ChatRoom.php
namespace App\Livewire;
use App\Models\Conversation;
use App\Models\Message;
use Livewire\Attributes\Validate;
use Livewire\Component;
class ChatRoom extends Component
{
public Conversation $conversation;
#[Validate('required|string|max:2000')]
public string $body = '';
public array $messages = [];
public function mount(Conversation $conversation): void
{
$this->conversation = $conversation;
$this->messages = $conversation->messages()
->with('user')
->oldest()
->get()
->map(fn (Message $message) => $this->format($message))
->all();
}
public function sendMessage(): void
{
$this->validate();
$message = $this->conversation->messages()->create([
'user_id' => auth()->id(),
'body' => $this->body,
]);
$this->messages[] = $this->format($message->load('user'));
$this->reset('body');
}
protected function format(Message $message): array
{
return [
'id' => $message->id,
'body' => $message->body,
'user_id' => $message->user_id,
'user_name' => $message->user->name,
];
}
public function render()
{
return view('livewire.chat-room');
}
}
The matching Blade view renders the bubbles and a submit form. A tiny bit of Alpine keeps the thread scrolled to the newest message. For a nicer composer that grows with the text, drop in the auto-growing textarea from Tailwind v4:
{{-- resources/views/livewire/chat-room.blade.php --}}
<div class="mx-auto flex h-[32rem] max-w-lg flex-col rounded-xl border border-gray-200">
<div class="flex-1 space-y-3 overflow-y-auto p-4"
x-data x-init="$el.scrollTop = $el.scrollHeight"
x-on:message-appended.window="$nextTick(() => $el.scrollTop = $el.scrollHeight)">
@foreach ($messages as $message)
<div @class(['flex', 'justify-end' => $message['user_id'] === auth()->id()])>
<div @class([
'max-w-xs rounded-2xl px-4 py-2 text-sm',
'bg-indigo-600 text-white' => $message['user_id'] === auth()->id(),
'bg-gray-100 text-gray-900' => $message['user_id'] !== auth()->id(),
])>
<p class="text-xs font-semibold opacity-70">{{ $message['user_name'] }}</p>
<p>{{ $message['body'] }}</p>
</div>
</div>
@endforeach
</div>
<form wire:submit="sendMessage" class="flex gap-2 border-t border-gray-200 p-3">
<input type="text" wire:model="body" autocomplete="off" placeholder="Type a message…"
class="flex-1 rounded-full border-gray-300 text-sm" />
<button type="submit" class="rounded-full bg-indigo-600 px-5 py-2 text-sm font-medium text-white">
Send
</button>
</form>
</div>
Right now this is a perfectly good non-real-time chat: submit persists a message and appends it for the sender. Pull it up in two browsers and you will see the gap — the other window stays frozen until it reloads. Closing that gap is the next step. If you want the input validated as the user types rather than on submit, the Livewire 4 form object pattern is a clean place to move this logic as the component grows.
Broadcast a MessageSent event on a private channel#
To push a new message to everyone else in the thread, broadcast an event when one is created. A dedicated event class gives you control over which channel it goes out on and exactly what payload rides the wire — far better than blasting the whole serialized model. Generate a broadcast event:
php artisan make:event MessageSent
Implement ShouldBroadcast, target a private channel scoped to the conversation, and hand-pick the payload with broadcastWith() so you never leak columns to connected clients. The broadcastAs() method gives the event a clean name that Echo (and Livewire) will listen for:
// app/Events/MessageSent.php
namespace App\Events;
use App\Models\Message;
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('conversation.'.$this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.sent';
}
/**
* @return array<string, mixed>
*/
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'body' => $this->message->body,
'user_id' => $this->message->user_id,
'user_name' => $this->message->user->name,
];
}
}
Now fire it from sendMessage(), immediately after persisting. The critical detail is ->toOthers(): the sender already appended their own message optimistically in the previous step, so broadcasting to everyone would give them a duplicate. toOthers() excludes the current user's socket, so only the other participants receive it:
use App\Events\MessageSent;
public function sendMessage(): void
{
$this->validate();
$message = $this->conversation->messages()->create([
'user_id' => auth()->id(),
'body' => $this->body,
]);
$this->messages[] = $this->format($message->load('user'));
broadcast(new MessageSent($message))->toOthers();
$this->reset('body');
}
Because MessageSent implements ShouldBroadcast (not ShouldBroadcastNow), the broadcast is queued — so you need a queue worker running, or the message never leaves the box. For a snappier chat, swap the contract to ShouldBroadcastNow to skip the queue and hit Reverb synchronously. It is the single most common "why is nothing broadcasting?" trip-up, and the Laravel broadcasting docs spell out the queued-versus-synchronous trade-off before you pick one.
Listen for broadcasts in Livewire and append messages live#
With the event going out on a private channel, the receiving side is pure Livewire — no message-handling JavaScript at all. Livewire speaks to Echo through its echo: and echo-private: listener prefixes, so you register a listener that maps the incoming broadcast to a component method. Because the channel name contains a runtime value (the conversation ID), define it with getListeners() rather than the static #[On] attribute:
public function getListeners(): array
{
return [
"echo-private:conversation.{$this->conversation->id},.message.sent" => 'onMessageReceived',
];
}
Two details make or break this line. The channel is conversation.{id} — matching the PrivateChannel in the event — and the event name is .message.sent with a leading dot. That dot tells Echo to treat it as the literal broadcastAs() name instead of prefixing it with your app's event namespace. Drop the dot and the listener silently never fires. The handler receives the broadcastWith() payload and appends it to the same $messages array the view already loops over:
public function onMessageReceived(array $event): void
{
$this->messages[] = [
'id' => $event['id'],
'body' => $event['body'],
'user_id' => $event['user_id'],
'user_name' => $event['user_name'],
];
$this->dispatch('message-appended');
}
That is the whole real-time loop. Open two browser windows, send from one, and the message appears in the other instantly — the message-appended browser event nudges the Alpine scroll handler to follow the newest bubble. You are appending the lightweight broadcast payload rather than re-querying, which keeps the interaction cheap even in a busy thread. If you also want to lazily pull in older history as the user scrolls up, the wire:intersect infinite-scroll pattern drops straight into the message pane.
Add a presence channel for online users#
A real chat shows who is around. That is exactly what presence channels are for: they are private channels that additionally track membership, firing "here", "joining", and "leaving" events as clients connect and disconnect. Livewire subscribes to them through the echo-presence: prefix, so you can maintain an online-user list without leaving PHP. Extend getListeners() to subscribe to the presence side of the same conversation channel:
public function getListeners(): array
{
$channel = "conversation.{$this->conversation->id}";
return [
"echo-private:{$channel},.message.sent" => 'onMessageReceived',
"echo-presence:{$channel},here" => 'syncOnlineUsers',
"echo-presence:{$channel},joining" => 'userJoined',
"echo-presence:{$channel},leaving" => 'userLeft',
];
}
Reusing the base name conversation.{id} for both the private and presence subscriptions is deliberate: on the wire they become the distinct Reverb channels private-conversation.{id} and presence-conversation.{id}, but a single authorization callback (next step) serves both. Add a $onlineUsers property and the three handlers — here receives the full member list, while joining and leaving each receive one member:
public array $onlineUsers = [];
public function syncOnlineUsers(array $users): void
{
$this->onlineUsers = collect($users)
->mapWithKeys(fn (array $user) => [$user['id'] => $user['name']])
->all();
}
public function userJoined(array $user): void
{
$this->onlineUsers[$user['id']] = $user['name'];
}
public function userLeft(array $user): void
{
unset($this->onlineUsers[$user['id']]);
}
Render the roster wherever you like — a header count works well:
<div class="flex items-center gap-2 border-b border-gray-200 px-4 py-2 text-xs text-gray-500">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{{ count($onlineUsers) }} online
</div>
The member identity each client publishes — the id and name you see above — comes from the channel authorization callback, which returns an array for presence channels instead of a bare boolean. That callback is the security boundary for the entire feature, so it gets its own step shortly.
Show a typing indicator with whispers#
"Steven is typing…" should never touch your database or your queue — it is ephemeral, high-frequency, and only interesting to people currently looking at the thread. The right tool is a client whisper: an event that hops client-to-client straight through Reverb without ever hitting your server. Every subscriber to the presence channel can whisper and listen for whispers. Wire it up with a small Alpine layer on the component root:
<div
x-data="{
typers: {},
get typingLabel() {
const names = Object.values(this.typers);
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} is typing…`;
return 'Several people are typing…';
},
init() {
const channel = window.Echo.join(`conversation.{{ $conversation->id }}`);
channel.listenForWhisper('typing', (e) => {
this.typers[e.id] = e.name;
clearTimeout(this.timers?.[e.id]);
(this.timers ??= {})[e.id] = setTimeout(() => delete this.typers[e.id], 2000);
});
this.$refs.input.addEventListener('input', () => {
channel.whisper('typing', { id: {{ auth()->id() }}, name: @js(auth()->user()->name) });
});
},
}"
>
<p x-show="typingLabel" x-text="typingLabel"
class="px-4 py-1 text-xs italic text-gray-400"></p>
{{-- ...message list and form, with x-ref="input" on the text input... --}}
</div>
Each keystroke whispers a typing event carrying the sender's id and name; every other client stores that name and clears it after two seconds of silence. Reverb throttles whispers to ten per second per client automatically, so you do not need to debounce manually, though an Alpine-side throttle is easy if you want to be frugal. Nothing here queues a job, writes a row, or round-trips to PHP — which is exactly why a typing indicator built this way stays cheap even when a dozen people are hammering the keyboard. If you would rather drive the indicator from component state, wire:entangle bridges Alpine and Livewire cleanly, as covered in sharing modal state with wire:entangle.
Authorize every channel in routes/channels.php#
Everything so far trusts that only conversation participants can subscribe — now enforce it. Channel authorization is the security boundary for the whole feature: without it, anyone who guesses a conversation ID can subscribe to the private channel and read every message in real time. Define one authorization callback for the conversation channel in routes/channels.php:
// routes/channels.php
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('conversation.{conversation}', function (User $user, Conversation $conversation) {
if (! $conversation->users()->whereKey($user->id)->exists()) {
return null;
}
return ['id' => $user->id, 'name' => $user->name];
});
That single callback covers both subscriptions. When Echo authorizes the private message.sent channel, any non-null return authorizes it. When it authorizes the presence channel, the returned array becomes the member's identity — the id and name that flow into the here, joining, and leaving handlers from the previous step. Returning null denies both, so a user who is not in the conversation_user pivot cannot subscribe to either. Laravel binds the {conversation} segment to a Conversation model automatically, so you get the record without a manual lookup.
Because authorization runs a query on every subscribe, keep it lean and indexed — the pivot's composite primary key already makes whereKey() an index hit. Model binding here also means a missing conversation returns a 404 at the auth endpoint rather than leaking a distinction between "forbidden" and "does not exist." With this in place, the real-time layer is functionally complete and safe; what remains is proving it works and shipping it.
Test the chat with Pest#
A real-time feature is only trustworthy if it is tested, and the good news is you do not need a browser or a live socket to test the important parts. Event::fake() lets you assert that sending a message persists it and dispatches the broadcast to others, all inside a fast Livewire component test. Create a Pest test:
php artisan make:test ChatRoomTest --pest
Drive the component the way a user would — set the body, call sendMessage, then assert the database and the broadcast:
// tests/Feature/ChatRoomTest.php
use App\Events\MessageSent;
use App\Livewire\ChatRoom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
it('persists a message and broadcasts it to other participants', function () {
Event::fake([MessageSent::class]);
$user = User::factory()->create();
$conversation = Conversation::factory()->create();
$conversation->users()->attach($user);
Livewire::actingAs($user)
->test(ChatRoom::class, ['conversation' => $conversation])
->set('body', 'Shipping this today')
->call('sendMessage')
->assertSet('body', '')
->assertSee('Shipping this today');
expect($conversation->messages()->count())->toBe(1);
Event::assertDispatched(MessageSent::class,
fn (MessageSent $event) => $event->message->body === 'Shipping this today'
);
});
it('requires a non-empty body', function () {
$user = User::factory()->create();
$conversation = Conversation::factory()->create();
$conversation->users()->attach($user);
Livewire::actingAs($user)
->test(ChatRoom::class, ['conversation' => $conversation])
->set('body', '')
->call('sendMessage')
->assertHasErrors(['body' => 'required']);
});
Run it with php artisan test --filter=ChatRoomTest. The first test proves the full send path end-to-end without a WebSocket in sight; the second guards validation. For the authorization callback, a focused test that attaches one user and asserts a non-participant is rejected gives you confidence the security boundary holds. Testing the component and the event this way is far more valuable than manually clicking between two browser windows — it catches the dot-prefix and toOthers() regressions that are otherwise invisible until production.
Deploy Reverb to production#
Locally, reverb:start in a terminal is enough. In production, Reverb is a long-lived process that has to survive restarts, run over TLS, and — if you kept ShouldBroadcast — sit behind a running queue worker. Point Echo at a dedicated WebSocket subdomain and set the server host explicitly so Reverb binds correctly behind your web server:
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_HOST=ws.example.com
REVERB_PORT=443
REVERB_SCHEME=https
Supervise php artisan reverb:start with Supervisor or systemd so it restarts on crash and reloads cleanly with reverb:restart on deploy, and run at least one queue worker so queued broadcasts actually go out. Terminate TLS at your web server or load balancer and proxy the WebSocket upgrade to Reverb's port. The moment you move beyond a single node — multiple app servers, thousands of connections, sticky sessions, presence at scale — you need Redis pub/sub and a WebSocket-aware health check, all of which the production Reverb scaling playbook walks through in full.
From here the chat is real and shippable. Natural next steps: add file attachments with direct-to-S3 temporary uploads in Livewire, or, if your product is heading toward AI, stream assistant replies token-by-token using the same broadcast foundation, as shown in streaming LLM responses over SSE with Livewire. You now have the whole first-party stack — Reverb, private and presence channels, typing whispers, authorization, and tests — in one coherent build you can extend with confidence.
FAQ#
How do I build a chat app in Laravel?
Model conversations and messages in the database, render the thread with a Livewire component, and broadcast a MessageSent event on a private channel whenever a message is created. Livewire listens for that broadcast with an echo-private: listener and appends the new message live, so no message-handling JavaScript is required. Laravel Reverb provides the WebSocket server, and channel authorization in routes/channels.php ensures only participants receive a conversation's messages. Persist first, broadcast second — the database stays the source of truth and the socket carries a live copy.
Is Laravel Reverb free?
Yes. Reverb is a first-party, open-source WebSocket server maintained by the Laravel team and released under the MIT license, so there are no per-message or per-connection fees the way a hosted service like Pusher or Ably charges. You self-host it — running php artisan reverb:start under a process supervisor — which means your only costs are the server resources it consumes. That makes it a strong fit for chat, where connection counts and message volume can climb quickly and metered pricing gets expensive fast.
What's the difference between private and presence channels?
A private channel is authorized — a user must pass the callback in routes/channels.php to subscribe — but it does not track who is connected. A presence channel is a private channel that additionally maintains a live membership list and fires "here", "joining", and "leaving" events as clients connect and disconnect. Use a private channel for the message stream itself, and a presence channel when you need to know who is online, such as for an active-user list or a typing indicator. In this build both share one authorization callback, which returns a boolean-equivalent value for the private subscription and a member-identity array for the presence one.
How do I show a typing indicator with Livewire?
Use client whispers on a presence channel rather than server events. A whisper is a client-to-client message that travels through Reverb without hitting your server, so it never queues a job or writes to the database. On each keystroke, call channel.whisper('typing', { id, name }); every other subscriber listens with channel.listenForWhisper('typing', ...) and shows the name for a couple of seconds before clearing it. A small Alpine layer handles this on the component root, keeping the indicator entirely client-side and cheap even under heavy typing.
How do I authorize a Reverb broadcast channel?
Define an authorization callback in routes/channels.php keyed to the channel name, using a route-model-bound wildcard for the dynamic part. The callback receives the authenticated user and the bound model and returns a truthy value to authorize or null/false to deny. For a conversation channel, check that the user belongs to the conversation's participant pivot. For presence channels, return an array of member data (like id and name) instead of a boolean — that array becomes the member identity broadcast to other subscribers. Authorization runs on every subscribe, so keep the query indexed and lean.
Do I need Laravel Echo with Livewire?
Yes — Echo is the browser-side client that opens the WebSocket connection and subscribes to channels, and Livewire's echo:, echo-private:, and echo-presence: listeners are thin wrappers over it. The install:broadcasting --reverb command sets Echo up for you, injecting a configured instance into resources/js/echo.js and installing the laravel-echo and pusher-js packages. Once window.Echo is available globally, Livewire can register broadcast listeners and you never write raw Echo subscription code for the message flow. You only reach for Echo directly for client whispers, such as the typing indicator.
How do I scale Reverb for many concurrent chat users?
A single Reverb node handles a few thousand connections; beyond that, set REVERB_SCALING_ENABLED=true and provision a shared Redis instance so broadcasts fan out across every node via pub/sub. Behind a load balancer you also need sticky sessions on the WebSocket upgrade, a WebSocket-aware health check (a plain HTTP GET returns 426 and looks unhealthy), and a raised file-descriptor limit since every connection is a file descriptor. Supervise the process, monitor connection counts, and add nodes once one host passes roughly 70% sustained CPU. The production scaling guide covers each of these knobs in detail.
Why isn't my Livewire component updating when the event broadcasts?
Four culprits cause almost every case. First, the queue: ShouldBroadcast queues the broadcast, so with no queue worker running nothing goes out — run a worker or switch the event to ShouldBroadcastNow. Second, the event name: when you use broadcastAs(), the Livewire listener must prefix the name with a dot (.message.sent), or Echo looks for the wrong event and the listener never fires. Third, the channel name must match exactly between the event's broadcastOn() and the listener, including the conversation ID. Fourth, authorization: if the channel callback returns null, the subscribe fails silently — watch reverb:start --debug and the browser console to see which subscription is being rejected.