Real-time chat requires WebSockets, presence channels, and complex state management. Laravel broadcasting with Pusher makes it straightforward. At ZIRA Software, we've built chat systems handling thousands of concurrent users with Laravel's elegant broadcasting API.
Architecture Overview
Components:
- Laravel backend (API + broadcasting)
- Pusher (WebSocket server)
- Vue.js frontend
- Redis (pub/sub)
- MySQL (message persistence)
Installation
composer require pusher/pusher-php-server
npm install --save laravel-echo pusher-js
.env:
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
PUSHER_APP_CLUSTER=mt1
Database Schema
Messages table:
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('conversation_id')->constrained();
$table->text('body');
$table->timestamps();
});
Conversations table:
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->string('name')->nullable();
$table->timestamps();
});
Schema::create('conversation_user', function (Blueprint $table) {
$table->foreignId('conversation_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->timestamp('last_read_at')->nullable();
});
Broadcasting Event
Create event:
php artisan make:event MessageSent
app/Events/MessageSent.php:
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
public function __construct(Message $message)
{
$this->message = $message;
}
public function broadcastOn()
{
return new PresenceChannel('conversation.' . $this->message->conversation_id);
}
public function broadcastWith()
{
return [
'id' => $this->message->id,
'body' => $this->message->body,
'user' => [
'id' => $this->message->user->id,
'name' => $this->message->user->name,
'avatar' => $this->message->user->avatar,
],
'created_at' => $this->message->created_at->toIso8601String(),
];
}
}
Broadcasting Authorization
routes/channels.php:
Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) {
return $user->conversations()
->where('conversation_id', $conversationId)
->exists();
});
Send Message Controller
<?php
namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function store(Request $request, Conversation $conversation)
{
$this->validate($request, [
'body' => 'required|max:500',
]);
$message = $conversation->messages()->create([
'user_id' => auth()->id(),
'body' => $request->body,
]);
$message->load('user');
broadcast(new MessageSent($message))->toOthers();
return response()->json($message, 201);
}
public function index(Conversation $conversation)
{
return $conversation->messages()
->with('user')
->latest()
->paginate(50);
}
}
Frontend Setup
resources/js/bootstrap.js:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: process.env.MIX_PUSHER_APP_KEY,
cluster: process.env.MIX_PUSHER_APP_CLUSTER,
forceTLS: true,
authEndpoint: '/broadcasting/auth',
auth: {
headers: {
Authorization: 'Bearer ' + localStorage.getItem('auth_token'),
},
},
});
Vue Chat Component
resources/js/components/Chat.vue:
<template>
<div class="chat">
<div class="messages" ref="messages">
<div v-for="message in messages" :key="message.id" class="message">
<img :src="message.user.avatar" :alt="message.user.name">
<div class="content">
<strong>{{ message.user.name }}</strong>
<p>{{ message.body }}</p>
<small>{{ formatTime(message.created_at) }}</small>
</div>
</div>
</div>
<div class="typing" v-if="typing.length">
<span v-for="user in typing" :key="user.id">
{{ user.name }} is typing...
</span>
</div>
<form @submit.prevent="sendMessage">
<input
v-model="newMessage"
@input="handleTyping"
placeholder="Type a message..."
maxlength="500"
>
<button type="submit">Send</button>
</form>
</div>
</template>
<script>
export default {
props: ['conversationId', 'initialMessages'],
data() {
return {
messages: this.initialMessages || [],
newMessage: '',
typing: [],
typingTimer: null,
};
},
mounted() {
this.joinConversation();
this.scrollToBottom();
},
methods: {
joinConversation() {
Echo.join(`conversation.${this.conversationId}`)
.here((users) => {
console.log('Users in conversation:', users);
})
.joining((user) => {
console.log(user.name + ' joined');
})
.leaving((user) => {
console.log(user.name + ' left');
})
.listen('MessageSent', (e) => {
this.messages.push(e.message);
this.$nextTick(() => {
this.scrollToBottom();
});
})
.listenForWhisper('typing', (e) => {
this.handleRemoteTyping(e);
});
},
sendMessage() {
if (!this.newMessage.trim()) return;
axios.post(`/conversations/${this.conversationId}/messages`, {
body: this.newMessage,
})
.then(response => {
this.messages.push(response.data);
this.newMessage = '';
this.$nextTick(() => {
this.scrollToBottom();
});
});
},
handleTyping() {
clearTimeout(this.typingTimer);
Echo.join(`conversation.${this.conversationId}`)
.whisper('typing', {
name: this.currentUser.name,
});
this.typingTimer = setTimeout(() => {
Echo.join(`conversation.${this.conversationId}`)
.whisper('typing', {
name: null,
});
}, 1000);
},
handleRemoteTyping(e) {
if (e.name) {
if (!this.typing.find(u => u.name === e.name)) {
this.typing.push({ name: e.name });
}
} else {
this.typing = this.typing.filter(u => u.name !== e.name);
}
},
scrollToBottom() {
this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight;
},
formatTime(timestamp) {
return moment(timestamp).format('h:mm A');
},
},
};
</script>
Typing Indicators
Client-side whisper:
Echo.join('conversation.1')
.whisper('typing', {
name: user.name,
});
Listen for typing:
.listenForWhisper('typing', (e) => {
if (e.name) {
// User is typing
} else {
// User stopped typing
}
});
Online Status
Presence channel shows who's online:
Echo.join('conversation.1')
.here((users) => {
// All users currently in channel
this.onlineUsers = users;
})
.joining((user) => {
// New user joined
this.onlineUsers.push(user);
})
.leaving((user) => {
// User left
this.onlineUsers = this.onlineUsers.filter(u => u.id !== user.id);
});
Read Receipts
Mark as read:
public function markAsRead(Conversation $conversation)
{
$conversation->users()
->updateExistingPivot(auth()->id(), [
'last_read_at' => now(),
]);
broadcast(new MessageRead(auth()->user(), $conversation));
return response()->json(['success' => true]);
}
Event:
class MessageRead implements ShouldBroadcast
{
public $user;
public $conversation;
public function broadcastOn()
{
return new PresenceChannel('conversation.' . $this->conversation->id);
}
}
Unread Count
public function unreadCount()
{
return auth()->user()->conversations()
->with(['messages' => function ($query) {
$query->where('created_at', '>', function ($query) {
$query->select('last_read_at')
->from('conversation_user')
->whereColumn('conversation_id', 'conversations.id')
->where('user_id', auth()->id());
});
}])
->get()
->sum(function ($conversation) {
return $conversation->messages->count();
});
}
File Uploads
public function store(Request $request, Conversation $conversation)
{
$this->validate($request, [
'body' => 'required_without:file|max:500',
'file' => 'required_without:body|file|max:10240',
]);
$message = $conversation->messages()->create([
'user_id' => auth()->id(),
'body' => $request->body,
]);
if ($request->hasFile('file')) {
$path = $request->file('file')->store('chat-files', 's3');
$message->update([
'file_url' => Storage::disk('s3')->url($path),
'file_name' => $request->file('file')->getClientOriginalName(),
]);
}
$message->load('user');
broadcast(new MessageSent($message))->toOthers();
return response()->json($message, 201);
}
Pagination & Lazy Loading
loadMoreMessages() {
axios.get(`/conversations/${this.conversationId}/messages`, {
params: { page: this.currentPage + 1 }
})
.then(response => {
this.messages.unshift(...response.data.data);
this.currentPage++;
});
}
Testing
/** @test */
public function message_is_broadcast_when_sent()
{
Event::fake([MessageSent::class]);
$user = factory(User::class)->create();
$conversation = factory(Conversation::class)->create();
$conversation->users()->attach($user);
$this->actingAs($user)
->post("/conversations/{$conversation->id}/messages", [
'body' => 'Hello World',
]);
Event::assertDispatched(MessageSent::class);
}
Best Practices
- Queue broadcasts - Don't block requests
- Authenticate channels - Verify access
- Persist messages - Database backup
- Limit message length - Prevent abuse
- Rate limit - Prevent spam
- Handle disconnections - Graceful failures
- Monitor costs - Pusher pricing
Conclusion
Laravel broadcasting makes real-time chat achievable without complex WebSocket servers. From typing indicators to presence channels, Laravel provides elegant APIs for sophisticated features. At ZIRA Software, Laravel-powered chat handles thousands of concurrent users reliably.
Start with basic messaging. Add typing indicators and presence. Build the features your users need.
Need real-time features for your application? Contact ZIRA Software to discuss WebSocket architecture, broadcasting strategies, and scalable real-time systems.