Back to blog
Laravel
Advanced

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

This comprehensive tutorial walks you through creating a scalable real‑time chat application with Laravel, WebSockets, and Redis. You'll master architecture, implementation, deployment, and performance tuning.

August 27, 202519 min read

Introduction

Real‑time communication has become a baseline expectation for modern web applications. Whether you are building a customer support widget, a collaborative editor, or a community chat, the ability to push messages instantly to connected clients is essential. Laravel, with its expressive syntax and robust ecosystem, makes it surprisingly straightforward to add real‑time features when combined with a WebSocket server and Redis as a message broker.

In this guide we will walk through the complete lifecycle of a scalable chat application: from architectural decisions and environment setup, through a step‑by‑step implementation, to production hardening, performance tuning, and debugging strategies. By the end you will have a fully functional codebase that can serve thousands of concurrent users with minimal latency.

Table of Contents

Core Concepts

WebSockets vs. Long Polling

WebSockets provide a full‑duplex, persistent connection between client and server, enabling true push semantics. Long polling, while easier to implement on restricted hosts, incurs higher latency and greater server load because each request opens a new HTTP connection. For a chat application that demands sub‑second delivery, WebSockets are the clear choice.

Redis Pub/Sub as a Message Broker

Redis's publish/subscribe mechanism decouples the WebSocket server from your Laravel application. When a user sends a message, the Laravel backend publishes an event to a Redis channel. All WebSocket server instances subscribed to that channel receive the payload and broadcast it to their connected clients. This pattern scales horizontally because you can run multiple WebSocket nodes behind a load balancer without sticky sessions.

Laravel Broadcasting & Echo

Laravel's broadcasting abstraction lets you define server‑side events that implement ShouldBroadcast. Laravel Echo, a JavaScript library, provides a fluent API to listen for those events on the client side. Together they hide the complexities of channel authorization, presence, and reconnection logic.

Architecture Overview

The system consists of three primary layers:

  1. API Layer (Laravel) – Handles authentication, message persistence, validation, and publishes events to Redis.
  2. Message Broker (Redis) – Runs in cluster mode for high availability; channels are namespaced per conversation (e.g., chat.123).
  3. WebSocket Layer (Laravel WebSockets / Soketi) – Subscribes to Redis channels, maintains persistent connections, and pushes payloads to authorized clients via Laravel Echo.

Horizontal scaling is achieved by adding more WebSocket nodes. A TCP/HTTP load balancer (NGINX or HAProxy) distributes incoming WebSocket upgrades using the ip_hash or least_conn algorithm. Because the WebSocket servers are stateless aside from the in‑memory connection map, any node can serve any client as long as they share the same Redis backend.

Step‑by‑Step Guide

1. Provision Infrastructure

Spin up a Redis instance (managed service like AWS ElastiCache or a self‑hosted cluster). Ensure the maxmemory-policy is set to allkeys-lru to avoid out‑of‑memory errors under heavy load. Create a dedicated database for broadcasting (e.g., database 1) to isolate it from cache data.

2. Install Laravel Packages

Run composer require beyondcode/laravel-websockets and composer require predis/predis. Publish the configuration and migration files: php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="migrations" then migrate.

3. Configure Broadcasting

In config/broadcasting.php set the default driver to redis and define the Redis connection to use the broadcasting database. Enable the pusher compatibility layer so Laravel Echo can connect using the standard Pusher protocol.

4. Create Chat Models & Migrations

Generate a Message model with fields: id, conversation_id, user_id, content, created_at. Add indexes on conversation_id and created_at for fast pagination.

5. Define Broadcast Events

Create MessageSent implementing ShouldBroadcastNow. The broadcastOn method returns a PrivateChannel('chat.' . $this->message->conversation_id). Include broadcastWith to shape the payload (message ID, user, content, timestamp).

6. Build API Endpoints

Expose POST /api/conversations/{id}/messages that validates the request, stores the message, and fires event(new MessageSent($message)). Protect routes with auth:sanctum middleware.

7. Set Up Front‑End with Laravel Echo

Install laravel-echo and pusher-js via npm. Initialize Echo with broadcaster: 'pusher', key: 'local', wsHost: window.location.hostname, wsPort: 6001, forceTLS: false, disableStats: true. Listen on private-chat.{conversationId} and append incoming messages to the UI.

8. Run the WebSocket Server

Start the server with php artisan websockets:serve --port=6001. In production, run it under a process manager (Supervisor, systemd, or Kubernetes) with multiple workers to utilize all CPU cores.

9. Test Locally

Open two browser tabs, authenticate as different users, join the same conversation, and send messages. Verify instantaneous delivery and persistence in the database.

Real‑World Examples

Customer Support Widget

A SaaS company embeds the chat widget on its marketing site. Visitors start anonymous conversations; agents receive real‑time notifications via the same WebSocket infrastructure. Presence channels show which agents are online, enabling smart routing.

Community Forum Live Threads

Each forum thread gets a dedicated private channel. When a user replies, the event broadcasts to all subscribers, updating the thread view without a page reload. Moderation actions (delete, pin) are also broadcast as separate event types.

Collaborative Document Editing

While not a pure chat, the same broadcasting pipeline can push operational transforms or CRDT updates to all collaborators. The message payload contains the operation instead of plain text, and the front‑end applies it to the editor state.

Production Code Examples

MessageSent Event

message->conversation_id);    }    public function broadcastWith(): array    {        return [            'id' => $this->message->id,            'conversation_id' => $this->message->conversation_id,            'user' => [                'id' => $this->message->user->id,                'name' => $this->message->user->name,                'avatar' => $this->message->user->avatar_url,            ],            'content' => $this->message->content,            'created_at' => $this->message->created_at->toIso8601String(),        ];    }}

Controller Store Method

validate([            'content' => 'required|string|max:10000',        ]);        $this->authorize('participate', $conversation);        $message = $conversation->messages()->create([            'user_id' => $request->user()->id,            'content' => $request->string('content'),        ]);        // Fire the broadcast event immediately        event(new MessageSent($message));        return response()->json($message->load('user'), 201);    }}

Echo Client Initialization

import Echo from 'laravel-echo';import Pusher from 'pusher-js';window.Pusher = Pusher;const echo = new Echo({    broadcaster: 'pusher',    key: import.meta.env.VITE_PUSHER_APP_KEY ?? 'local',    wsHost: import.meta.env.VITE_WS_HOST ?? window.location.hostname,    wsPort: import.meta.env.VITE_WS_PORT ?? 6001,    forceTLS: false,    disableStats: true,    authEndpoint: '/api/broadcasting/auth',    auth: {        headers: {            Authorization: `Bearer ${localStorage.getItem('access_token')}`,        },    },});export default echo;

Listening for Messages in Vue Component

import { ref, onMounted, onUnmounted } from 'vue';import echo from '@/utils/echo';const messages = ref([]);let channel = null;onMounted(() => {    channel = echo.private(`chat.${props.conversationId}`);    channel.listen('MessageSent', (e) => {        messages.value.push(e);    });});onUnmounted(() => {    if (channel) {        echo.leave(`chat.${props.conversationId}`);    }});

Comparison Table

FeaturePusher (Hosted)Laravel WebSockets (Self‑Hosted)Soketi (Standalone)
Operational OverheadLow (managed)Medium (runs inside Laravel)Low (single binary)
Cost at ScaleHigh (per connection pricing)Low (infrastructure only)Low (infrastructure only)
Custom MiddlewareLimitedFull Laravel middleware stackCustom via Go plugins
Horizontal ScalingAutomaticRequires Redis + multiple workersBuilt‑in clustering
Protocol CompatibilityPusher protocolPusher protocolPusher protocol
Debug DashboardPusher dashboardLaravel WebSockets dashboardBuilt‑in web UI

Best Practices

  • Use Private Channels for any user‑specific data. Presence channels are ideal for online/offline indicators.
  • Validate & Sanitize all inbound message content on the server side; never trust client‑side filtering.
  • Persist First, Broadcast Second – Store the message in the database before firing the event to guarantee durability.
  • Leverage Redis Cluster for high availability; configure redis.cluster in config/database.php.
  • Implement Rate Limiting per user per conversation (e.g., 30 messages/minute) using Laravel's throttle middleware.
  • Monitor Connection Metrics – Track active connections, message throughput, and latency via the WebSocket dashboard or Prometheus exporters.
  • Graceful Reconnection – Configure Echo's reconnect options and handle close events to restore UI state.

Common Mistakes

  • Forgetting to run php artisan websockets:clean periodically, leading to stale connection entries in the dashboard.
  • Using the default database Redis connection for broadcasting, causing cache eviction to drop pub/sub messages.
  • Not authorizing private channels correctly – the Broadcast::channel closure must return true only for participants.
  • Blocking the event loop with heavy logic inside the WebSocket worker (e.g., synchronous HTTP calls). Offload to queues.
  • Hard‑coding the WebSocket port in front‑end code; use environment variables for port and host.
  • Neglecting SSL termination at the load balancer – WebSocket upgrades must be proxied with Upgrade and Connection headers.

Performance Tips

  • Batch Redis Publications – If you anticipate burst traffic, accumulate messages in a short‑lived buffer (e.g., 50 ms) and publish once.
  • Enable HTTP/2 on the load balancer; it reduces header overhead for the initial WebSocket handshake.
  • Use Connection Pooling for Redis (Predis or phpredis) to avoid reconnect overhead per request.
  • Compress Payloads – Enable perMessageDeflate on the WebSocket server; configure Echo with enableCompression: true.
  • Shard Conversations – Distribute high‑traffic conversations across multiple Redis channels (e.g., chat.123.shard1) and subscribe workers accordingly.
  • Profile with XHProf/Blackfire – Identify bottlenecks in the event broadcasting path.

Security Considerations

  • Authentication & Authorization – All broadcasting routes must be protected by auth:sanctum or JWT middleware. Private channel authorization callbacks must verify conversation membership.
  • Content Security Policy – Restrict script sources to your domain; allow connect-src for the WebSocket endpoint.
  • Input Validation – Enforce maximum message length, strip HTML tags, and escape output on the front end to prevent XSS.
  • Rate Limiting & Abuse Detection – Implement per‑IP and per‑user limits; monitor for spam patterns and auto‑ban offenders.
  • Secure Redis – Bind Redis to private network interfaces, enable ACLs, and use TLS if traffic traverses untrusted networks.
  • WebSocket Origin Checks – Configure the WebSocket server to reject connections from unexpected origins.

Deployment Notes

Containerize the Laravel application and the WebSocket server separately. A typical Docker Compose stack includes:

  • app – PHP‑FPM + Nginx (serves HTTP API and static assets).
  • websockets – PHP CLI running php artisan websockets:serve with multiple replicas.
  • redis – Redis 7 cluster (3 masters + 3 replicas).
  • queue – Horizon workers for background jobs (notifications, email, analytics).

Use Kubernetes Deployments with HorizontalPodAutoscaler based on CPU and custom metrics (active WebSocket connections). Configure NGINX Ingress with proxy_http_version 1.1 and proxy_set_header Upgrade $http_upgrade to forward WebSocket upgrades.

Run database migrations and php artisan websockets:clean as part of the CI/CD pipeline before rolling out new versions. Enable zero‑downtime deployments by draining connections: send a SIGTERM to the WebSocket process, stop accepting new upgrades, wait for existing connections to close (max 30 s), then terminate.

Debugging Tips

  • Laravel WebSockets Dashboard – Visit /laravel-websockets to see real‑time connection counts, channel subscriptions, and message throughput.
  • Redis MONITOR – Run redis-cli MONITOR to verify that events are published on the expected channels.
  • Echo Debug Mode – Set Echo.connector.pusher.connection.bind_all((event, data) => console.log(event, data)) in the browser console to trace inbound events.
  • Log Broadcasting Events – Add a listener for Illuminate\Broadcasting\Events\BroadcastEventCreated to write payloads to the log for audit.
  • Network Tab – Inspect the WebSocket handshake (101 Switching Protocols) and frames; look for missing Upgrade headers if connections fail behind a proxy.
  • Load Testing – Use wrk or k6 with WebSocket scripts to simulate thousands of concurrent connections and measure latency.

FAQ

Can I use this setup with Laravel Octane?

Yes. Octane (RoadRunner or Swoole) can serve the HTTP API while a separate WebSocket process handles persistent connections. They share the same Redis backend, so broadcasting works unchanged.

Do I need a separate Redis instance for broadcasting?

It is strongly recommended. Using a dedicated Redis database (or separate instance) isolates pub/sub traffic from cache eviction policies and prevents cache flushes from dropping messages.

How do I handle mobile push notifications when the app is backgrounded?

Combine WebSocket delivery with a fallback: when a message is broadcast, also dispatch a queued job that sends a Firebase/APNs push if the recipient is offline (detected via presence channel or a last‑seen timestamp).

What is the maximum message size WebSocket frames can carry?

WebSocket frames support up to 2^63‑1 bytes, but practical limits are imposed by server configuration (e.g., maxPayload in Laravel WebSockets). Keep messages under a few kilobytes; for larger payloads, send a reference (URL) instead.

How do I scale the WebSocket server beyond a single host?

Run multiple websockets:serve processes behind a TCP load balancer with sticky sessions disabled. All instances subscribe to the same Redis channels, so any node can deliver messages to its local connections.

Is it possible to integrate with a third‑party chat UI library like CometChat?

Absolutely. CometChat and similar SDKs accept a custom WebSocket endpoint. Point them at your Laravel WebSockets server and implement the required authentication handshake.

What monitoring tools work well with this stack?

Prometheus + Grafana for metrics (connection count, messages/sec), Laravel Telescope for request‑level insight, and Sentry for error tracking. The WebSocket dashboard also exposes a JSON endpoint for custom scrapers.

How do I secure the WebSocket endpoint against DDoS?

Place the WebSocket server behind a WAF (Cloudflare, AWS Shield) that can rate‑limit upgrade requests. Additionally, enforce authentication before the upgrade by validating the token in the query string or header during the handshake middleware.

Conclusion

You now have a complete blueprint for a production‑grade, horizontally scalable real‑time chat system built with Laravel, WebSockets, and Redis. The architecture separates concerns cleanly: Laravel handles business logic and persistence, Redis provides a fast, reliable message bus, and the WebSocket layer delivers low‑latency push to every connected client.

Start by scaffolding the project with the steps above, then iterate: add presence indicators, message reactions, file uploads, or end‑to‑end encryption. Each feature builds on the same solid foundation.

Ready to ship real‑time experiences? Clone the companion repository, run the Docker stack, and watch your first message travel from browser to database to every subscriber in milliseconds. If you found this guide valuable, share it with your team, star the repo, and subscribe to the newsletter for more deep‑dives into Laravel performance and architecture.