Introduction
Real‑time communication has become a core requirement for modern web applications. From customer support widgets to collaborative editing tools, users expect instant updates without page reloads. Laravel's broadcasting system, combined with a WebSocket server, gives PHP developers a powerful yet approachable stack for building such features.
In this article you will learn how to design, implement, and deploy a scalable real‑time chat application using Laravel 11, the native broadcasting API, and a self‑hosted WebSocket server such as Soketi. We cover everything from core concepts to production‑grade code, performance tuning, security hardening, and debugging techniques.
By the end of this guide you will have a fully functional chat demo that can serve thousands of concurrent connections on modest hardware, plus a clear mental model for extending the system with presence, private channels, and message persistence.
Table of Contents
- Introduction
- Core Concepts
- Architecture Overview
- Step‑by‑Step Guide
- Real‑World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
Core Concepts
Laravel broadcasting abstracts the transport layer so you can swap Pusher, Soketi, or any compatible WebSocket server without changing application code. The key building blocks are events, channels, and the Echo client library.
An event class implements the ShouldBroadcast interface (or ShouldBroadcastNow for synchronous dispatch). When the event is fired, Laravel serialises its public properties and pushes the payload to the configured broadcaster.
Channels come in three flavours: public, private, and presence. Public channels allow anyone to subscribe. Private channels require an authentication endpoint that returns a signed token. Presence channels extend private channels with member awareness, perfect for showing who is online in a chat room.
Laravel Echo is a tiny JavaScript wrapper around the WebSocket connection. It handles reconnection, channel authorisation, and provides a fluent API for listening to events. Echo works with both Pusher protocol and the open‑source Soketi server.
Understanding the difference between broadcast drivers is crucial. The pusher driver talks to any Pusher‑compatible endpoint. The log driver writes events to the log file for local debugging. The null driver discards events. For production you will use the pusher driver pointed at your own Soketi instance.
Architecture Overview
The high‑level architecture consists of four logical layers: the HTTP API layer, the broadcasting layer, the WebSocket layer, and the client layer.
The HTTP API layer is a standard Laravel application serving REST endpoints for authentication, conversation metadata, and message history. It also hosts the broadcast authentication route (/broadcasting/auth) used by private and presence channels.
The broadcasting layer lives inside the same Laravel process. When a user sends a message, the controller fires a MessageSent event. Laravel's event dispatcher hands the event to the configured broadcaster (pusher driver). The broadcaster formats the payload and sends an HTTP POST to the WebSocket server's /apps/{app_id}/events endpoint.
The WebSocket layer (Soketi) maintains persistent TCP connections with each client. Upon receiving the event payload from Laravel, Soketi fans out the message to all subscribed sockets on the target channel. Soketi also handles presence bookkeeping, heartbeats, and optional clustering via Redis.
The client layer is a single‑page application (Vue, React, or plain JavaScript) that loads Laravel Echo, authenticates the user, subscribes to the appropriate channels, and renders incoming events in real time.
This separation allows horizontal scaling: you can run multiple Laravel workers behind a load balancer, and multiple Soketi nodes behind a Redis‑backed pub/sub cluster, all sharing the same application state.
Step‑by‑Step Guide
1. Install Laravel and Required Packages
Create a fresh Laravel 11 project and install the broadcasting dependencies. Run the following commands in your terminal.
composer create-project laravel/laravel chat-appcd chat-appcomposer require beyondcode/laravel-websocketsphp artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations"php artisan migratecomposer require laravel/echo pusher-jsThe beyondcode/laravel-websockets package provides a Pusher‑compatible server (Soketi) and a dashboard for monitoring connections.
2. Configure Environment Variables
Edit the .env file to enable the pusher driver and point it at the local WebSocket server.
BROADCAST_DRIVER=pusherPUSHER_APP_ID=localPUSHER_APP_KEY=localPUSHER_APP_SECRET=localPUSHER_HOST=127.0.0.1PUSHER_PORT=6001PUSHER_SCHEME=httpVITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"VITE_PUSHER_HOST="${PUSHER_HOST}"VITE_PUSHER_PORT="${PUSHER_PORT}"VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"If you plan to use Soketi directly instead of the beyondcode package, replace the host and port with your Soketi instance values.
3. Create the Chat Models and Migrations
Generate a Conversation model, a Message model, and the corresponding migrations.
php artisan make:model Conversation -mphp artisan make:model Message -mDefine the schema so that a conversation belongs to many users (participants) and has many messages. Use a pivot table for participants.
4. Build the Broadcast Event
Create an event that implements ShouldBroadcastNow for immediate delivery.
php artisan make:event MessageSent --broadcastEdit the generated class to carry the message data and specify the channel.
namespace App\Events;use App\Models\Message;use Illuminate\Broadcasting\Channel;use Illuminate\Broadcasting\InteractsWithSockets;use Illuminate\Broadcasting\PresenceChannel;use Illuminate\Broadcasting\PrivateChannel;use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;use Illuminate\Foundation\Events\Dispatchable;use Illuminate\Queue\SerializesModels;class MessageSent implements ShouldBroadcastNow { 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'; } public function broadcastWith(): array { return [ 'id' => $this->message->id, 'body' => $this->message->body, 'user_id' => $this->message->user_id, 'created_at' => $this->message->created_at->toIso8601String(), ]; }}5. Authorise Private Channels
In routes/channels.php register the authorisation callback.
use App\Models\Conversation;use Illuminate\Support\Facades\Broadcast;Broadcast::channel('conversation.{conversationId}', function ($user, $conversationId) { return Conversation::where('id', $conversationId) ->whereHas('participants', fn($q) => $q->where('user_id', $user->id)) ->exists();});6. Implement the Message Controller
Create a controller that stores the message and fires the event.
php artisan make:controller Api/MessageController --apinamespace App\Http\Controllers\Api;use App\Events\MessageSent;use App\Models\Conversation;use App\Models\Message;use Illuminate\Http\Request;use App\Http\Controllers\Controller;class MessageController extends Controller { public function store(Request $request, Conversation $conversation) { $request->validate(['body' => 'required|string|max:5000']); $message = $conversation->messages()->create([ 'user_id' => $request->user()->id, 'body' => $request->body, ]); broadcast(new MessageSent($message))->toOthers(); return response()->json($message, 201); }}7. Register API Routes
Add the route in routes/api.php.
use App\Http\Controllers\Api\MessageController;use App\Models\Conversation;Route::middleware('auth:sanctum')->group(function () { Route::post('/conversations/{conversation}/messages', [MessageController::class, 'store']);});8. Set Up the Front‑End with Laravel Echo
In your JavaScript entry point (resources/js/app.js) initialise Echo.
import Echo from 'laravel-echo'window.Pusher = require('pusher-js')window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, wssPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: import.meta.env.VITE_PUSHER_SCHEME === 'https', disabledTransports: ['ws', 'wss'], authEndpoint: '/broadcasting/auth', auth: { headers: { Authorization: `Bearer ${document.querySelector('meta[name="token"]').content}` } }})9. Subscribe and Listen in a Component
In a Vue component (or React) join the private channel and handle incoming events.
const conversationId = props.conversation.idconst messages = ref([])Echo.private(`conversation.${conversationId}`) .listen('message.sent', (e) => { messages.value.push(e) })async function sendMessage(body) { await axios.post(`/api/conversations/${conversationId}/messages`, { body })}10. Run the WebSocket Server
Start the beyondcode WebSocket server (which runs Soketi under the hood).
php artisan websockets:serveVisit the dashboard at /laravel-websockets to verify connections.
Real‑World Examples
Many production systems use this exact stack. A SaaS help‑desk product replaced its third‑party chat widget with a custom Laravel‑WebSocket solution, reducing latency from 300 ms to under 50 ms and cutting monthly costs by 80 %. Another example is a collaborative whiteboard where presence channels show live cursors; the same broadcasting infrastructure powers both chat and presence.
In a high‑traffic marketplace, the engineering team deployed a cluster of three Soketi nodes behind a Redis pub/sub layer. Laravel workers publish events to Redis; each Soketi node receives the fan‑out and delivers to its local connections. This architecture handles 20 000 concurrent users on a modest 4‑CPU, 16 GB RAM instance per node.
For mobile applications, the same events are consumed by a React‑Native client using the pusher‑js library with a native WebSocket polyfill. The backend does not change; only the client side adapts.
Production Code Examples
Event with Conditional Broadcasting
Sometimes you only want to broadcast to online users. Use the PresenceChannel to check membership.
public function broadcastOn(): array { return [new PresenceChannel('conversation.' . $this->message->conversation_id)];}Rate Limiting Message Creation
Protect the API from abuse with a throttle middleware.
Route::post('/conversations/{conversation}/messages', [MessageController::class, 'store']) ->middleware('throttle:30,1'); // 30 requests per minutePersisting Read Receipts
When a client receives a message, send an acknowledgment back to the server.
Echo.private(`conversation.${conversationId}`) .listen('message.sent', async (e) => { messages.value.push(e) await axios.post(`/api/messages/${e.id}/read`) })Server‑Side Read Receipt Handler
public function markRead(Message $message) { $message->reads()->firstOrCreate(['user_id' => request()->user()->id]); return response()->json(['status' => 'ok']);}Comparison Table
| Feature | Pusher (Hosted) | Soketi (Self‑Hosted) | Laravel WebSockets (BeyondCode) |
|---|---|---|---|
| Protocol Compatibility | Pusher native | Pusher compatible | Pusher compatible |
| Cost | Pay per connection | Infrastructure only | Infrastructure only |
| Scalability | Automatic | Redis clustering | Redis clustering |
| Dashboard | Built‑in | Optional (Soketi UI) | Built‑in dashboard |
| Custom Logic | Webhooks only | Middleware hooks | Middleware hooks |
| Maintenance Overhead | None | Medium | Low (Artisan command) |
Best Practices
Always use private or presence channels for user‑specific data. Public channels are fine for global notifications but never for personal messages.
Keep event payloads small. Only broadcast identifiers and minimal content; let the client fetch full details via an API call if needed.
Leverage Laravel's queue system for heavy work (e.g., sending email notifications) while the broadcast event remains lightweight and fast.
Enable HTTPS in production. Set PUSHER_SCHEME=https and configure Soketi with SSL certificates. This protects the WebSocket upgrade handshake and subsequent frames.
Monitor connection counts and message throughput. The beyondcode dashboard or Soketi's Prometheus exporter gives you real‑time metrics for autoscaling decisions.
Version your broadcast events. If you change the payload shape, introduce a new event class (MessageSentV2) and keep the old one for backward compatibility during rolling deployments.
Common Mistakes
Forgetting to run the broadcasting authentication route. Without /broadcasting/auth the client cannot join private channels and will receive a 403 error.
Using the log driver in production. The log driver writes every event to storage/logs/laravel.log, quickly filling disks and adding latency.
Not handling reconnection logic. While Echo reconnects automatically, you must re‑subscribe to channels after a disconnect; otherwise the client misses messages sent during the downtime.
Broadcasting to the sender with toOthers() omitted. If you want the sender to see their own message instantly via the UI (optimistic update) you can skip broadcasting to them, but forgetting to call toOthers() results in duplicate rendering.
Hard‑coding the WebSocket host in JavaScript. Always read the host, port, and scheme from environment variables (Vite imports) to avoid mismatches between local and production builds.
Performance Tips
Run Soketi with the --cluster flag and a Redis backend when you exceed a single node's capacity. Each node will share subscription state via Redis pub/sub.
Enable HTTP/2 on your reverse proxy (Nginx or Caddy). WebSocket upgrades benefit from the multiplexed connection, reducing handshake overhead.
Use Laravel Octane (Swoole or RoadRunner) to keep the application bootstrapped in memory. This cuts the per‑request latency for the /broadcasting/auth endpoint and any API calls triggered by the chat UI.
Batch presence updates. Instead of firing a presence event on every keystroke, debounce on the client and send a single