Realizzare una chat in tempo reale con WebSocket e Redis in Laravel
Costruire una chat in tempo reale è uno dei casi d'uso più interessanti per chi vuole superare il classico modello richiesta/risposta di HTTP. Laravel offre un'infrastruttura di broadcasting molto matura che, combinata con Redis come Pub/Sub backend e un server WebSocket, permette di realizzare un sistema scalabile, performante e relativamente semplice da mantenere. In questo articolo vedremo come progettare e implementare una chat completa, partendo dalla configurazione dell'ambiente fino al client JavaScript che riceve i messaggi in tempo reale.
Architettura del sistema
Prima di scrivere codice, è importante avere ben chiara l'architettura. Il flusso di una chat in tempo reale con Laravel si compone di diversi attori che lavorano insieme. Il client invia il messaggio al backend tramite una normale richiesta HTTP (POST), Laravel persiste il messaggio nel database e contemporaneamente lo pubblica su un canale Redis tramite il sistema di broadcasting. Il server WebSocket, sottoscritto a quel canale Redis, riceve la notifica e la inoltra a tutti i client connessi al canale corrispondente. I client, attraverso Laravel Echo, ricevono l'evento e aggiornano l'interfaccia.
Questa separazione tra il processo PHP che gestisce le richieste HTTP e il processo Node che gestisce le connessioni WebSocket è cruciale: PHP non è progettato per mantenere connessioni persistenti, mentre Node è perfetto per questo compito. Redis fa da ponte tra i due mondi.
Requisiti e installazione
Per questo progetto useremo Laravel 11, Redis come Pub/Sub e Soketi come server WebSocket compatibile con il protocollo Pusher. Soketi è gratuito, open source e si integra perfettamente con Laravel Echo. In alternativa si potrebbe usare Laravel Reverb, il server WebSocket ufficiale rilasciato con Laravel 11, ma Soketi rimane una scelta solida e ben documentata per chi ha esigenze di compatibilità con il protocollo Pusher.
Creiamo il progetto e installiamo le dipendenze necessarie:
composer create-project laravel/laravel chat-app
cd chat-app
composer require predis/predis
composer require laravel/sanctum
php artisan install:broadcasting
Il comando install:broadcasting è una novità di Laravel 11 che configura automaticamente il broadcasting, pubblica il file routes/channels.php e installa le dipendenze JavaScript di Echo. Procediamo poi installando Soketi a livello globale tramite npm:
npm install -g @soketi/soketi
npm install --save-dev laravel-echo pusher-js
Configurazione di Redis e del broadcasting
Modifichiamo il file .env per indicare a Laravel di usare Redis come driver di broadcasting e di code, e per configurare le credenziali del nostro server Soketi:
BROADCAST_CONNECTION=pusher
QUEUE_CONNECTION=redis
CACHE_STORE=redis
SESSION_DRIVER=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
PUSHER_APP_ID=chat-app
PUSHER_APP_KEY=local-key
PUSHER_APP_SECRET=local-secret
PUSHER_HOST=127.0.0.1
PUSHER_PORT=6001
PUSHER_SCHEME=http
PUSHER_APP_CLUSTER=mt1
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
Apriamo poi config/broadcasting.php e verifichiamo che la connessione Pusher sia configurata correttamente. Aggiungiamo nella sezione options la chiave encrypted impostata a false dato che stiamo lavorando in locale senza TLS:
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST', 'api-'.env('PUSHER_APP_CLUSTER').'.pusher.com'),
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => false,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
],
Configurazione di Soketi
Creiamo nella root del progetto un file soketi.config.json per istruire Soketi su come gestire l'applicazione e come parlare con Redis:
{
"debug": true,
"port": 6001,
"host": "127.0.0.1",
"appManager": {
"driver": "array",
"array": {
"apps": [
{
"id": "chat-app",
"key": "local-key",
"secret": "local-secret",
"webhooks": [],
"maxConnections": 1000,
"enableClientMessages": false,
"enabled": true
}
]
}
},
"adapter": {
"driver": "redis",
"redis": {
"prefix": "soketi"
}
},
"database": {
"redis": {
"host": "127.0.0.1",
"port": 6379,
"db": 0
}
}
}
L'uso dell'adapter Redis è fondamentale se vogliamo poter scalare orizzontalmente Soketi su più processi o macchine: tutti i nodi condividono lo stato delle connessioni tramite Redis Pub/Sub.
Migrazioni e modelli
Definiamo le tabelle necessarie. Una chat ha bisogno almeno di users (già presente), rooms per le stanze di conversazione, room_user come pivot per la partecipazione, e messages per i messaggi. Creiamo le migrazioni:
php artisan make:model Room -m
php artisan make:model Message -m
php artisan make:migration create_room_user_table
La migrazione per le rooms:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('rooms', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('rooms');
}
};
La migrazione pivot per room_user:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('room_user', function (Blueprint $table) {
$table->id();
$table->foreignId('room_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamp('joined_at')->useCurrent();
$table->unique(['room_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('room_user');
}
};
La migrazione per i messaggi:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('room_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->text('body');
$table->timestamps();
// Indice per ottimizzare le query di caricamento cronologico
$table->index(['room_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};
Eseguiamo le migrazioni con php artisan migrate e definiamo i modelli. Il modello Room:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Room extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug', 'description', 'owner_id'];
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
public function members(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('joined_at')
->withTimestamps();
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
}
Il modello Message:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Message extends Model
{
use HasFactory;
protected $fillable = ['room_id', 'user_id', 'body'];
protected $casts = [
'created_at' => 'datetime',
];
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
Creazione dell'evento di broadcasting
Il cuore del sistema in tempo reale è l'evento broadcast. Quando un utente invia un messaggio, vogliamo che venga propagato a tutti i partecipanti della stanza. Creiamo l'evento:
php artisan make:event MessageSent
Modifichiamo il file app/Events/MessageSent.php in questo modo:
<?php
namespace App\Events;
use App\Models\Message;
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 function __construct(public Message $message)
{
// Eager loading dell'utente per evitare query N+1 lato client
$this->message->loadMissing('user');
}
public function broadcastOn(): PresenceChannel
{
return new PresenceChannel('room.' . $this->message->room_id);
}
public function broadcastAs(): string
{
return 'message.sent';
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'body' => $this->message->body,
'room_id' => $this->message->room_id,
'created_at' => $this->message->created_at->toIso8601String(),
'user' => [
'id' => $this->message->user->id,
'name' => $this->message->user->name,
],
];
}
}
Tre dettagli importanti. Implementiamo ShouldBroadcast (e non ShouldBroadcastNow) così l'evento viene messo in coda e processato dal worker Redis, evitando di bloccare la richiesta HTTP. Usiamo un PresenceChannel invece di un canale privato semplice perché ci dà gratuitamente la lista dei presenti, utile per mostrare chi è online. Definiamo broadcastWith() per controllare esattamente quali dati arrivano al client, evitando di esporre informazioni sensibili.
Autorizzazione dei canali
I canali presence richiedono autorizzazione. Solo gli utenti che fanno parte della room devono potersi sottoscrivere. Modifichiamo routes/channels.php:
<?php
use App\Models\Room;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('room.{roomId}', function ($user, int $roomId) {
$room = Room::find($roomId);
if (!$room) {
return false;
}
// Verifico che l'utente sia membro della stanza
if (!$room->members()->where('user_id', $user->id)->exists()) {
return false;
}
// Restituisco i dati che saranno visibili agli altri presenti
return [
'id' => $user->id,
'name' => $user->name,
];
});
Il valore restituito da questa closure viene incluso nelle informazioni di presence inviate agli altri membri del canale: in pratica, quando un utente entra, tutti gli altri sanno chi è.
Controller per l'invio dei messaggi
Creiamo un controller per gestire la creazione dei messaggi via API:
php artisan make:controller Api/MessageController --api
Il controller si occupa di validare l'input, persistere il messaggio e dispatchare l'evento:
<?php
namespace App\Http\Controllers\Api;
use App\Events\MessageSent;
use App\Http\Controllers\Controller;
use App\Models\Message;
use App\Models\Room;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MessageController extends Controller
{
public function index(Request $request, Room $room): JsonResponse
{
// Verifico che l'utente possa leggere i messaggi della stanza
if (!$room->members()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$messages = $room->messages()
->with('user:id,name')
->latest()
->take(50)
->get()
->reverse()
->values();
return response()->json([
'data' => $messages,
]);
}
public function store(Request $request, Room $room): JsonResponse
{
$validated = $request->validate([
'body' => ['required', 'string', 'max:2000'],
]);
// Verifico l'appartenenza prima di salvare
if (!$room->members()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$message = $room->messages()->create([
'user_id' => $request->user()->id,
'body' => $validated['body'],
]);
// Dispatch dell'evento broadcast (verrà processato dal worker della coda)
broadcast(new MessageSent($message))->toOthers();
return response()->json([
'data' => $message->load('user:id,name'),
], 201);
}
}
La chiamata toOthers() è importante: evita di reinviare il messaggio al mittente, che lo ha già visualizzato localmente come ottimizzazione UX (optimistic UI). Per farlo funzionare, dal client dobbiamo passare l'header X-Socket-ID con l'identificatore della connessione WebSocket corrente, ma Laravel Echo lo fa automaticamente se configurato come vedremo più avanti.
Rotte API
Definiamo le rotte in routes/api.php:
<?php
use App\Http\Controllers\Api\MessageController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
Route::get('/rooms/{room}/messages', [MessageController::class, 'index']);
Route::post('/rooms/{room}/messages', [MessageController::class, 'store']);
});
Configurazione di Laravel Echo lato client
Apriamo resources/js/echo.js (creato automaticamente da install:broadcasting) e configuriamolo per puntare a Soketi:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_APP_KEY,
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
wsHost: import.meta.env.VITE_PUSHER_HOST,
wsPort: import.meta.env.VITE_PUSHER_PORT ?? 6001,
wssPort: import.meta.env.VITE_PUSHER_PORT ?? 6001,
forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
authEndpoint: '/broadcasting/auth',
});
L'endpoint /broadcasting/auth è quello che Laravel espone automaticamente per autorizzare i canali privati e presence. Echo userà la sessione corrente per autenticare la sottoscrizione.
Client JavaScript della chat
Implementiamo ora il client che si connette al canale presence, ascolta gli eventi e invia i messaggi tramite API:
import './echo';
import axios from 'axios';
// Imposto axios per inviare automaticamente il CSRF token e il socket ID
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.withCredentials = true;
class ChatRoom {
constructor(roomId, currentUserId) {
this.roomId = roomId;
this.currentUserId = currentUserId;
this.channel = null;
this.messagesContainer = document.getElementById('messages');
this.input = document.getElementById('message-input');
this.form = document.getElementById('message-form');
this.presenceList = document.getElementById('presence-list');
}
async init() {
await this.loadHistory();
this.subscribeToChannel();
this.bindForm();
}
async loadHistory() {
try {
const response = await axios.get(`/api/rooms/${this.roomId}/messages`);
response.data.data.forEach((message) => this.renderMessage(message));
this.scrollToBottom();
} catch (error) {
console.error('Errore nel caricamento dello storico', error);
}
}
subscribeToChannel() {
this.channel = window.Echo.join(`room.${this.roomId}`)
.here((users) => {
// Lista iniziale dei presenti
this.updatePresence(users);
})
.joining((user) => {
this.addPresence(user);
})
.leaving((user) => {
this.removePresence(user);
})
.listen('.message.sent', (event) => {
this.renderMessage(event);
this.scrollToBottom();
})
.error((error) => {
console.error('Errore canale presence', error);
});
}
bindForm() {
this.form.addEventListener('submit', async (event) => {
event.preventDefault();
const body = this.input.value.trim();
if (body.length === 0) {
return;
}
// UI ottimistica: aggiungo subito il messaggio
const optimisticId = `tmp-${Date.now()}`;
this.renderMessage({
id: optimisticId,
body,
user: { id: this.currentUserId, name: 'Tu' },
created_at: new Date().toISOString(),
pending: true,
});
this.input.value = '';
this.scrollToBottom();
try {
await axios.post(`/api/rooms/${this.roomId}/messages`, { body });
// Rimuovo lo stato di pending dal DOM
const element = document.getElementById(optimisticId);
if (element) {
element.classList.remove('pending');
}
} catch (error) {
console.error('Invio fallito', error);
const element = document.getElementById(optimisticId);
if (element) {
element.classList.add('failed');
}
}
});
}
renderMessage(message) {
const node = document.createElement('p');
node.id = String(message.id);
if (message.pending) {
node.classList.add('pending');
}
const time = new Date(message.created_at).toLocaleTimeString('it-IT', {
hour: '2-digit',
minute: '2-digit',
});
node.innerHTML = `<strong>${this.escape(message.user.name)}</strong> ` +
`<small>${time}</small><br>${this.escape(message.body)}`;
this.messagesContainer.appendChild(node);
}
updatePresence(users) {
this.presenceList.innerHTML = '';
users.forEach((user) => this.addPresence(user));
}
addPresence(user) {
const li = document.createElement('li');
li.id = `presence-${user.id}`;
li.textContent = user.name;
this.presenceList.appendChild(li);
}
removePresence(user) {
const li = document.getElementById(`presence-${user.id}`);
if (li) {
li.remove();
}
}
scrollToBottom() {
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
}
escape(value) {
// Escape minimo per prevenire iniezione di markup
const div = document.createElement('div');
div.textContent = value;
return div.innerHTML;
}
}
document.addEventListener('DOMContentLoaded', () => {
const root = document.getElementById('chat-root');
if (!root) {
return;
}
const chat = new ChatRoom(
Number(root.dataset.roomId),
Number(root.dataset.userId)
);
chat.init();
});
Avvio dei processi
Per far funzionare il sistema in sviluppo dobbiamo tenere attivi quattro processi: il server HTTP di Laravel, il worker della coda, Soketi e Vite. Apriamo quindi quattro terminali:
# Terminale 1: server Laravel
php artisan serve
# Terminale 2: worker della coda Redis
php artisan queue:work redis --queue=default
# Terminale 3: server WebSocket Soketi
soketi start --config=soketi.config.json
# Terminale 4: bundler frontend
npm run dev
In alternativa si può usare il comando php artisan serve in combinazione con un Procfile o uno strumento come concurrently per gestire tutti i processi insieme. Laravel 11 include anche il comando composer run dev che avvia diversi processi in parallelo, utile in fase di sviluppo.
Considerazioni sulla scalabilità
Il sistema descritto regge bene fino a qualche migliaio di connessioni concorrenti su una singola istanza Soketi. Quando si vuole scalare oltre, ci sono diverse strategie. La prima è l'horizontal scaling di Soketi: avviando più istanze dietro un load balancer con sticky session, l'adapter Redis sincronizza automaticamente i canali tra le istanze. La seconda è il tuning del worker: se il volume di messaggi è elevato, si dovrebbero avviare più worker della coda con php artisan queue:work --queue=broadcasts --tries=3 e usare una coda dedicata per gli eventi di broadcasting, separata dalle altre code applicative. La terza è l'introduzione di Redis Cluster o di Redis con replica per evitare che il Pub/Sub diventi un single point of failure.
Un'altra considerazione importante riguarda la persistenza dei messaggi. In questo articolo abbiamo salvato ogni messaggio nel database principale, ma se il volume cresce molto si può valutare una strategia ibrida: scrittura immediata su una cache write-behind (Redis o una tabella di buffer) e flush periodico verso il database definitivo, oppure l'uso di un database time-series o di un sistema dedicato come ScyllaDB per il log dei messaggi.
Sicurezza
Una chat in tempo reale apre diverse superfici di attacco. L'autorizzazione dei canali presence che abbiamo implementato in routes/channels.php è la prima linea di difesa: nessuno può sottoscriversi a una stanza di cui non è membro. La seconda è il rate limiting: dobbiamo proteggere l'endpoint di invio messaggi da abusi applicando un middleware come throttle:60,1 (sessanta messaggi al minuto per utente). La terza è la sanificazione lato server del contenuto, fondamentale se i messaggi possono contenere Markdown o HTML, mai fidarsi solo dell'escape lato client. Infine è opportuno disabilitare i client events su Soketi (impostazione enableClientMessages a false come nel nostro config) per evitare che i client possano emettere eventi arbitrari aggirando il backend.
Test
Laravel offre helper specifici per testare il broadcasting. Possiamo verificare che un evento venga dispatchato senza realmente inviarlo via WebSocket:
<?php
use App\Events\MessageSent;
use App\Models\Room;
use App\Models\User;
use Illuminate\Support\Facades\Event;
test('un utente membro può inviare un messaggio', function () {
Event::fake([MessageSent::class]);
$user = User::factory()->create();
$room = Room::factory()->create();
$room->members()->attach($user->id);
$response = $this->actingAs($user)
->postJson("/api/rooms/{$room->id}/messages", [
'body' => 'Ciao a tutti',
]);
$response->assertCreated();
Event::assertDispatched(MessageSent::class, function ($event) use ($room) {
return $event->message->room_id === $room->id
&& $event->message->body === 'Ciao a tutti';
});
});
test('un utente non membro non può inviare messaggi', function () {
$user = User::factory()->create();
$room = Room::factory()->create();
// Non aggiungo l'utente alla room
$response = $this->actingAs($user)
->postJson("/api/rooms/{$room->id}/messages", [
'body' => 'Non dovrei poter scrivere',
]);
$response->assertForbidden();
});
Conclusioni
Abbiamo costruito una chat in tempo reale completa appoggiandoci a tre tecnologie consolidate. Laravel gestisce la logica applicativa, la persistenza e l'autorizzazione, Redis fa da bus di comunicazione tra il backend PHP e il server WebSocket, e Soketi traduce gli eventi Redis in messaggi WebSocket per i client. Il pattern è estendibile facilmente: aggiungere indicatori "sta scrivendo", reazioni ai messaggi, conferme di lettura o supporto a file allegati richiede solo nuovi eventi broadcast e nuovi listener lato client, senza modificare l'infrastruttura di base. Il vero valore di questa architettura è la separazione netta delle responsabilità tra processi pensati per HTTP request/response e processi pensati per connessioni persistenti, una separazione che diventa cruciale appena il sistema deve scalare.