Real-time features used to mean wrestling with WebSocket servers, running a separate Node process, or paying for hosted infrastructure that was overkill for small apps. But Laravel Livewire combined with Pusher changes the game — you can build real-time interfaces entirely in PHP, without writing a single line of client-side JavaScript. For more details, check out Building a Real-Time Chat App with Laravel Livewire and Push. For more details, check out Setting Up a Python Development Environment on VirtualBox wi. For more details, check out Step-by-Step Guide: Setting Up Laravel with AdminLTE and Mul.
In this tutorial, I'll walk through building a real-time chat application step by step. We'll cover setup, broadcasting, error handling, and deployment considerations so you can adapt this to your own projects.
What We're Building

A real-time chat where messages appear instantly across all connected browsers — no page refresh, no polling, no custom WebSocket server. Livewire handles the component state, Pusher handles the real-time broadcast, and you write it all in Laravel.
Prerequisites
- Laravel 11 project (or Laravel 10 — both work)
- PHP 8.1+, Composer, Node.js 18+
- A Pusher account (free tier is fine — you get 200k messages/day)
- Basic understanding of Laravel and Livewire
Step 1: Set Up Your Laravel Project
Start fresh or add this to an existing app:
composer create-project laravel/laravel livewire-chat
cd livewire-chat
Set up your database in .env. For testing, SQLite works perfectly:
DB_CONNECTION=sqlite
DB_DATABASE=/path/to/livewire-chat/database/database.sqlite
Create the database file and run migrations:
touch database/database.sqlite
php artisan migrate
Step 2: Install Livewire
Laravel Livewire is a full-stack framework for Laravel that makes building dynamic interfaces simple. Install it:
composer require livewire/livewire
That's it — Livewire auto-registers in Laravel 11. If you're on Laravel 10, you'll need to add the routes middleware manually, but Laravel 11 picks it up automatically.
Step 3: Set Up Pusher
Head over to Pusher Channels and create a new app. Select Laravel as your backend and jQuery as your frontend (or whatever you prefer — the setup is the same). Note down your app credentials.
Install the Pusher PHP SDK:
composer require pusher/pusher-php-server
Update your .env with the Pusher credentials:
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=your-app-id
PUSHER_APP_KEY=your-app-key
PUSHER_APP_SECRET=your-app-secret
PUSHER_APP_CLUSTER=us2
Also update the broadcasting config in config/broadcasting.php. Laravel 11's default config already supports Pusher, so double-check the connections.pusher section references your env vars.
Install the JavaScript side:
npm install
npm install --save-dev pusher-js laravel-echo
In resources/js/bootstrap.js, uncomment and configure Echo:
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,
forceTLS: true,
});
Add your Pusher vars to .env and VITE_ prefixed ones for the frontend:
VITE_PUSHER_APP_KEY=${PUSHER_APP_KEY}
VITE_PUSHER_APP_CLUSTER=${PUSHER_APP_CLUSTER}
Build the frontend assets:
npm run build
Step 4: Create the Message Model and Migration
We need to persist messages so conversations survive page refreshes.
php artisan make:model Message -m
In the generated migration:
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('content');
$table->timestamps();
});
}
Run the migration:
php artisan migrate
Update the Message model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
protected $fillable = ['user_id', 'content'];
public function user()
{
return $this->belongsTo(User::class);
}
}
Step 5: Build the Livewire Chat Component
Generate the component:
php artisan make:livewire Chat
This creates two files: - app/Livewire/Chat.php - resources/views/livewire/chat.blade.php
Chat.php — the component class:
<?php
namespace App\Livewire;
use App\Models\Message;
use Livewire\Component;
class Chat extends Component
{
public $messages = [];
public $newMessage = '';
public $messageCount = 0;
protected $listeners = ['echo:chat-channel,MessageSent' => 'refreshMessages'];
public function mount()
{
$this->loadMessages();
}
public function loadMessages()
{
$this->messages = Message::with('user')
->latest()
->take(50)
->get()
->reverse()
->toArray();
$this->messageCount = count($this->messages);
}
public function sendMessage()
{
$this->validate([
'newMessage' => 'required|string|max:1000',
]);
$message = Message::create([
'user_id' => auth()->id(),
'content' => $this->newMessage,
]);
// Broadcast the event
broadcast(new \App\Events\MessageSent($message->load('user')));
$this->newMessage = '';
$this->loadMessages();
}
public function refreshMessages()
{
$this->loadMessages();
}
public function render()
{
return view('livewire.chat');
}
}
chat.blade.php — the view:
<div class="chat-container">
<div class="messages-box" wire:poll.5s="loadMessages">
@if(count($messages) === 0)
<p class="text-muted">No messages yet. Start the conversation!</p>
@else
@foreach($messages as $message)
<div class="message {{ $message['user_id'] === auth()->id() ? 'own-message' : 'other-message' }}">
<strong>{{ $message['user']['name'] }}</strong>
<p>{{ $message['content'] }}</p>
<small>{{ \Carbon\Carbon::parse($message['created_at'])->diffForHumans() }}</small>
</div>
@endforeach
@endif
</div>
<div class="typing-indicator" wire:loading wire:target="sendMessage">
Sending...
</div>
@error('newMessage')
<div class="alert alert-danger">{{ $message }}</div>
@enderror
<form wire:submit="sendMessage">
<input type="text"
wire:model="newMessage"
placeholder="Type your message..."
class="form-control">
<button type="submit" class="btn btn-primary mt-2" wire:loading.attr="disabled">
Send
</button>
</form>
</div>
Step 6: Create the Broadcast Event
php artisan make:event MessageSent
In app/Events/MessageSent.php:
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets;
public $message;
public function __construct(Message $message)
{
$this->message = $message;
}
public function broadcastOn(): Channel
{
return new Channel('chat-channel');
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'content' => $this->message->content,
'user_id' => $this->message->user_id,
'user_name' => $this->message->user->name,
'created_at' => $this->message->created_at->toDateTimeString(),
];
}
}
Register the broadcast channel in routes/channels.php (you may need to create this file):
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('chat-channel', function ($user) {
return true; // Allow all authenticated users
});
Step 7: Wire Up Routes and Layout
Update routes/web.php:
<?php
use App\Livewire\Chat;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::middleware(['auth'])->group(function () {
Route::get('/chat', Chat::class)->name('chat');
});
Create a layout at resources/views/layouts/app.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', config('app.name'))</title>
@vite(['resources/css/app.css'])
@livewireStyles
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">Livewire Chat</a>
<div class="ml-auto">
@auth
<span class="text-white mr-3">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}" class="d-inline">
@csrf
<button type="submit" class="btn btn-outline-light btn-sm">Logout</button>
</form>
@endauth
</div>
</div>
</nav>
<main class="py-4">
<div class="container">
@yield('content')
</div>
</main>
@vite(['resources/js/app.js'])
@livewireScripts
</body>
</html>
And the chat page at resources/views/chat.blade.php:
@extends('layouts.app')
@section('title', 'Chat Room')
@section('content')
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">Chat Room</div>
<div class="card-body">
@livewire('chat')
</div>
</div>
</div>
</div>
@endsection
Step 8: Test and Verify
Start the Laravel dev server and queue worker (for broadcast):
php artisan serve
php artisan queue:listen
In another terminal, build the assets:
npm run dev
Open http://localhost:8000/chat in two different browsers (or incognito windows). Log in as different users and send messages — you should see them pop up in real-time.
Common Issues and Fixes
Messages Not Appearing in Real-Time
Check that the queue worker is running (php artisan queue:listen). Without it, broadcast events queue up and never dispatch. Also verify your Pusher dashboard shows active connections.
Vite Not Loading Assets
Make sure you used npm run build (or npm run dev for hot reload). Check the browser console for 404s on asset URLs.
Authentication Error on Broadcast
The channel authorization route requires a logged-in user. If broadcast fails, verify you're authenticated and the routes/channels.php file exists and is registered.
Memory Issues with Large Chat History
The component loads 50 messages max. For production, add infinite scroll or pagination using Livewire's $this->loadMore() pattern.
Deployment Notes
When deploying to production:
- Queue driver: Switch to a proper queue driver like Redis or database (not
sync). Thesyncdriver works in development but blocks the request until the broadcast completes, slowing down message sending. - Broadcast events via Pusher: Make sure
QUEUE_CONNECTIONis set toredisordatabasein your.env. SetBROADCAST_DRIVER=pusher. - CORS and WebSocket connections: If your app is behind CloudFlare or a reverse proxy, make sure WebSocket connections can pass through. Pusher uses HTTPS, so this is usually fine.
- Rate limiting: Pusher free tier allows 200k messages/day. For busy chat apps, you may need to upgrade or batch updates.
Going Further
From here you can:
- Add typing indicators — broadcast a "user is typing" event triggered by
wire:keydown - Add message reactions with another Livewire component
- Implement private channels so users only see conversations they're part of
- Add file sharing with Livewire's
wire:modelfile uploads
Livewire + Pusher is a powerful combo for adding real-time features without JavaScript frameworks. The same pattern works for notifications, live dashboards, collaborative editing — anything where "it just updates" beats "hit refresh."