Back to blog
Laravel
Intermediate

Building a Scalable Real‑Time Chat Application with Laravel, WebSockets, and Redis

Create a high‑performance real‑time chat application with Laravel, WebSockets, and Redis. This comprehensive guide walks you through core concepts, architecture, code examples, and production deployment.

July 13, 202622 min read

Introduction

Real‑time communication has become a cornerstone of modern web applications. Whether you are building a customer‑support widget, a multiplayer game, or a team collaboration tool, users expect messages to appear instantly without refreshing the page. Laravel, with its expressive syntax and robust ecosystem, pairs beautifully with WebSockets and Redis to deliver low‑latency, horizontally scalable chat experiences.

In this article we will construct a full‑featured real‑time chat application from the ground up. You will learn the underlying concepts, design a resilient architecture, implement the client‑side and server‑side code, and finally prepare the system for production deployment. By the end you will have a reusable codebase that can be extended to group channels, typing indicators, read receipts, and more.

Table of Contents

Core Concepts

Before diving into code, make sure you understand the three pillars that power this solution:

  • WebSockets: A full‑duplex communication protocol that keeps a persistent TCP connection open between client and server. Unlike classic HTTP, data can be pushed from the server at any time.
  • Redis Pub/Sub: Redis can act as a lightweight message broker. When a user sends a chat message, the Laravel backend publishes the payload to a Redis channel; all subscribed WebSocket workers receive it instantly.
  • Laravel Echo & Echo Server: Echo is a JavaScript library that abstracts WebSocket connections. The Echo Server (a Node.js process) bridges Laravel events to the client via Socket.io.

These concepts together give us:

  1. Stateless HTTP routes for authentication and message persistence.
  2. A scalable, message‑driven layer (Redis) that decouples the HTTP process from the WebSocket process.
  3. Automatic broadcast of events to every connected client that is subscribed to the appropriate channel.

Architecture Overview

The diagram below (described in text) outlines the flow:

  1. Browser loads the SPA (single‑page application) and authenticates via Laravel Sanctum.
  2. Echo connects to the Laravel Echo Server over Socket.io using the authenticated token.
  3. When a user sends a message, an HTTP POST request reaches a Laravel controller.
  4. The controller stores the message in MySQL and fires a MessageSent event.
  5. Laravel's broadcast driver (Redis) publishes the event to the chat.{room_id} Redis channel.
  6. All Echo Server workers listening on that Redis channel forward the payload to connected browsers.
  7. Clients receive the event via Echo and render the new message instantly.

This design separates concerns:

  • HTTP layer handles auth, validation, and persistence.
  • Redis provides a fast, in‑memory pub/sub bus.
  • Echo Server scales horizontally; you can run multiple Node processes behind a load balancer.

Step‑By‑Step Guide

Prerequisites

  • PHP 8.2+, Composer, and Laravel 11 installed.
  • Node.js 20+ and npm.
  • Redis server (local or Docker).
  • MySQL 8+ (or any supported relational DB).

1. Create a Fresh Laravel Project

composer create-project laravel/laravel laravel-chatcd laravel-chat

2. Install Required Packages

composer require laravel/sanctumcomposer require predis/predisnpm install --save laravel-echo socket.io-client

3. Configure Sanctum for SPA Authentication

In config/sanctum.php set stateful to your dev domain:

'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),

Add the Sanctum middleware to api group in app/Http/Kernel.php:

'api' => [    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,    'throttle:api',    \Illuminate\Routing\Middleware\SubstituteBindings::class,],

4. Set Up the Database

Create a messages table:

php artisan make:migration create_messages_table --create=messages

Edit the migration:

Schema::create('messages', function (Blueprint $table) {    $table->id();    $table->foreignId('user_id')->constrained()->onDelete('cascade');    $table->foreignId('room_id')->constrained()->onDelete('cascade');    $table->text('body');    $table->timestamps();});

Run migrations:

php artisan migrate

5. Define the Message Model & Relationships

class Message extends Model {    protected $fillable = ['user_id', 'room_id', 'body'];    public function user() { return $this->belongsTo(User::class); }    public function room() { return $this->belongsTo(Room::class); }}

6. Create a Broadcast Event

php artisan make:event MessageSent

Populate the event (make it broadcastable):

class MessageSent implements ShouldBroadcast {    use Dispatchable, InteractsWithSockets, SerializesModels;    public $message;    public function __construct(Message $message) { $this->message = $message; }    public function broadcastOn() { return new Channel('chat.' . $this->message->room_id); }    public function broadcastWith() { return ['id' => $this->message->id, 'user' => $this->message->user->name, 'body' => $this->message->body, 'created_at' => $this->message->created_at->toDateTimeString()]; }}

7. Configure Broadcasting

In .env set:

BROADCAST_DRIVER=redisCACHE_DRIVER=redisQUEUE_CONNECTION=redisREDIS_HOST=127.0.0.1

Make sure the redis connection is defined in config/database.php.

8. Create a Chat Controller

php artisan make:controller ChatController

Controller logic:

class ChatController extends Controller {    public function fetchMessages($roomId) {        return Message::with('user')->where('room_id', $roomId)->latest()->take(50)->get();    }    public function sendMessage(Request $request, $roomId) {        $request->validate(['body' => 'required|string|max:500']);        $message = Message::create([            'user_id' => $request->user()->id,            'room_id' => $roomId,            'body' => $request->body,        ]);        broadcast(new MessageSent($message))->toOthers();        return response()->json($message->load('user'));    }}

9. Register API Routes

Route::middleware('auth:sanctum')->group(function () {    Route::get('/rooms/{room}/messages', [ChatController::class, 'fetchMessages']);    Route::post('/rooms/{room}/messages', [ChatController::class, 'sendMessage']);});

10. Install Laravel Echo Server

npm install -g laravel-echo-serverlaravel-echo-server init

During init answer:

  • App ID: laravel-chat
  • Host: localhost
  • Port: 6001
  • Database: redis
  • Auth host: http://localhost
  • Auth endpoint: /broadcasting/auth

Start the server in a separate terminal:

laravel-echo-server start

11. Front‑End Integration (Vue example)

Install Vue 3 (or React if you prefer). Here we use Vue for brevity.

npm install vue@next @vitejs/plugin-vue

Create resources/js/app.js:

import { createApp } from 'vue';import Echo from 'laravel-echo';import io from 'socket.io-client';import ChatComponent from './components/ChatComponent.vue';window.Echo = new Echo({  broadcaster: 'socket.io',  host: window.location.hostname + ':6001',  auth: {    headers: {      Authorization: `Bearer ${localStorage.getItem('sanctum_token')}`,    },  },});createApp({}).component('chat-component', ChatComponent).mount('#app');

Sample ChatComponent.vue (simplified):

<template>  <div class="chat">    <ul>      <li v-for="msg in messages" :key="msg.id">        <strong>{{ msg.user.name }}:</strong> {{ msg.body }}      </li>    </ul>    <input v-model="newMessage" @keyup.enter="send" placeholder="Type a message..." />  </div></template><script>import axios from 'axios';export default {  props: ['roomId'],  data() {    return { messages: [], newMessage: '' };  },  mounted() {    this.fetchHistory();    window.Echo.private(`chat.${this.roomId}`)      .listen('MessageSent', (e) => {        this.messages.unshift(e);      });  },  methods: {    fetchHistory() {      axios.get(`/api/rooms/${this.roomId}/messages`).then(r => {        this.messages = r.data.reverse();      });    },    send() {      if (!this.newMessage) return;      axios.post(`/api/rooms/${this.roomId}/messages`, { body: this.newMessage })        .then(() => { this.newMessage = ''; })        .catch(err => console.error(err));    },  },};</script><style scoped>.chat { max-width: 600px; margin: auto; }ul { list-style: none; padding: 0; }li { padding: 4px 0; }input { width: 100%; padding: 8px; }</style>

12. Run the Application

php artisan serve &npm run dev# Ensure laravel-echo-server is also running

Open two browser windows, log in with different users, join the same room component, and watch messages appear instantly.

Real‑World Examples

Several production systems employ a similar stack:

  • Slack‑like team chat: Private channels map to room_id values; Redis guarantees message ordering across multiple PHP‑FPM workers.
  • Customer support widgets: The visitor's session ID becomes the room; agents subscribe to many rooms simultaneously, and Redis's pub/sub scales to thousands of concurrent chats.
  • Live comment feeds: News sites broadcast comments in real time using the same MessageSent event, with a separate comments channel per article.

In all cases the key benefits remain low latency (< 200 ms typical), horizontal scalability, and a clean separation between HTTP and WebSocket concerns.

Production Code Examples

Message Model with Soft Deletes

use Illuminate\Database\Eloquent\SoftDeletes;class Message extends Model {    use SoftDeletes;    protected $fillable = ['user_id', 'room_id', 'body'];    protected $dates = ['deleted_at'];    // Relationships omitted for brevity}

Broadcast Service Provider Customization

public function boot(){    Broadcast::routes(['middleware' => ['auth:sanctum']]);    // Force all private channels to use Redis guard    Broadcast::channel('chat.{roomId}', function ($user, $roomId) {        return $user->can('view', \App\Models\Room::find($roomId));    });}

Queueing the Broadcast for Heavy Load

Dispatch the event to a queue to avoid blocking the HTTP request:

broadcast(new MessageSent($message))->onQueue('broadcasts');

Configure a Redis queue worker:

php artisan queue:work redis --queue=broadcasts --daemon

Docker Compose for Local Development

version: '3.8'services:  app:    build: .    ports: ['8000:8000']    environment:      - APP_KEY=base64:...      - DB_HOST=db      - REDIS_HOST=redis    depends_on: [db, redis]  db:    image: mysql:8    environment:      MYSQL_DATABASE: chat      MYSQL_ROOT_PASSWORD: secret    volumes: ['db_data:/var/lib/mysql']  redis:    image: redis:7    ports: ['6379:6379']  echo-server:    image: node:20    working_dir: /app    volumes: ['.:/app']    command: npx laravel-echo-server start    ports: ['6001:6001']    environment:      - REDIS_HOST=redisvolumes:  db_data:

Comparison Table

Feature Laravel Echo + Redis Pusher (hosted) Polling (fallback)
Latency ~30 ms (in‑memory) ~100 ms (network) > 1 s
Cost Self‑hosted (infrastructure) Pay‑per‑message Free (but wasteful)
Scalability Horizontal through Redis cluster + multiple Echo servers Managed scaling Limited
Auth Integration Native Laravel Sanctum / Passport Custom token mapping None (public)
Complexity Medium – Requires Redis & Echo Server Low – SDK handles everything Low – Simple Ajax

Best Practices

  • Use private channels: Never broadcast chat messages on public channels; always scope them by room ID and verify authorization.
  • Queue broadcasts: For high traffic, push events to a dedicated broadcast queue to keep request‑response cycles fast.
  • Limit payload size: Send only the data needed for the UI (id, user name, body, timestamp). Avoid sending large user objects.
  • Throttle message rate: Implement a server‑side rate limiter (e.g., 5 messages per second) to prevent spam and DoS.
  • Persist messages atomically: Wrap DB insert and event dispatch in a transaction to guarantee consistency.
  • Graceful reconnection: Echo automatically retries, but store unsent messages locally and resend after the connection re‑establishes.
  • Health‑check Redis and Echo: Use Laravel's health‑check routes or external monitoring to alert on downtime.

Common Mistakes

  • Broadcasting from within a transaction before the DB commit completes – leads to messages appearing before they are actually stored.
  • Using the default pusher driver while expecting Redis behavior – misconfiguration yields silent failures.
  • Exposing the Redis port publicly – a common security oversight that can lead to remote data injection.
  • Not clearing the Echo Server cache after code changes – stale channel definitions cause auth errors.
  • Relying on socket.io version mismatches between client and server – results in connection handshake failures.

Performance Tips

  • Enable Redis clustering: Distribute pub/sub traffic across shards when you exceed ~10k concurrent connections.
  • Compress messages: Use socket.io built‑in compression for large payloads (e.g., images as base64).
  • Use lazy loading for history: Load older messages on scroll instead of fetching the entire history at once.
  • Limit the number of sockets per user: If a user opens multiple tabs, share a single socket via a singleton pattern in the front‑end.
  • Monitor event loop latency: In the Echo Server, keep the Node event loop under 20 ms; otherwise, messages will be queued.

Security Considerations

  • Authentication: Use Sanctum tokens passed via the Authorization header; never rely on cookies alone for WebSocket auth.
  • Authorization: Define channel policies that verify the user belongs to the requested room.
  • Input sanitisation: Escape message body on rendering (e.g., use v-text in Vue) to avoid XSS.
  • Redis ACLs: Create a dedicated Redis user with only publish and subscribe privileges.
  • Rate limiting: Laravel's ThrottleRequests middleware on the POST route protects against flood attacks.

Deployment Notes

When moving to production, consider the following steps:

  1. Run Laravel and Echo Server behind a reverse proxy (NGINX) with SSL termination.
  2. Expose only port 6001 internally; use NGINX to proxy /ws to the Echo Server.
  3. Scale Echo Server processes with PM2 or Docker Swarm based on CPU usage.
  4. Configure Redis persistence (AOF) and enable maxmemory-policy allkeys-lru for memory management.
  5. Set up health‑checks: /health/redis, /health/echo, and Laravel /up endpoint.

Example NGINX snippet:

upstream echo_server {    server 127.0.0.1:6001;}server {    listen 443 ssl;    server_name chat.example.com;    ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;    ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;    location / {        proxy_pass http://127.0.0.1:8000;        proxy_set_header Host $host;        proxy_set_header X-Real-IP $remote_addr;    }    location /socket.io/ {        proxy_http_version 1.1;        proxy_set_header Upgrade $http_upgrade;        proxy_set_header Connection "upgrade";        proxy_pass http://echo_server;    }}

Debugging Tips

  • Echo Server logs: Run with DEBUG=* laravel-echo-server start to view subscription events.
  • Redis monitor: Use redis-cli monitor to see publish commands in real time.
  • Laravel Telescope: Install Telescope to trace broadcast events, queue jobs, and database queries.
  • Browser dev tools: Check the Network tab for the Socket.io handshake and any 403 responses.
  • Connection state: Echo provides .connected() and .error() callbacks for reactive UI status.

FAQ

Can I use Laravel Sail instead of Docker Compose?

Yes. Sail bundles PHP, MySQL, and Redis, but you will still need a separate Node container for the Echo Server or run it on the host.

Is the Redis pub/sub model durable?

No. Pub/sub messages are volatile – if a subscriber is offline it misses the message. Persist chat history in the database and load it on reconnection.

How many concurrent users can this architecture support?

The bottleneck is the number of open WebSocket connections. A single Redis instance comfortably handles 10‑20k sockets; beyond that, use Redis Cluster or a managed service such as AWS ElastiCache.

Do I need to run a separate queue worker for broadcasts?

It is recommended for production. Queueing prevents HTTP request latency spikes and allows you to horizontally scale workers.

What if I want group typing indicators?

Create a separate TypingIndicator event that broadcasts the user ID and a boolean isTyping. Because the payload is tiny, you can broadcast it without queueing.

Can I replace Socket.io with native WebSocket?

Yes, but you would lose features such as automatic reconnection, fallback transports, and channel authentication helpers that Echo provides.

How do I restrict a user to certain rooms?

Implement a policy method on the Room model and reference it in the channel definition (see the Broadcast Service Provider example).

Is there a way to encrypt message payloads end‑to‑end?

Laravel Echo does not provide built‑in E2EE. You would need to encrypt the message on the client before sending it to the server and store only the ciphertext. Decryption occurs on the receiving client.

Conclusion

Building a real‑time chat system with Laravel, Redis, and Laravel Echo Server gives you fine‑grained control, predictable latency, and the ability to scale horizontally without relying on third‑party SaaS providers. By following the architecture, code patterns, and best‑practice checklist presented here, you can ship a production‑ready chat feature that aligns with modern security standards and performance expectations. Give it a try — clone the repository, run the Docker compose file, and start experimenting with rooms, typing indicators, and read receipts. If you encounter any hurdles, revisit the debugging section or check the official Laravel, Redis, and Echo Server documentation linked above.