Build a Real-Time Chat App with Laravel, Pusher & Echo
Contents
- 1 Introduction
- 2 What You’ll Build
- 3 Technical Requirements
- 4 Project Architecture Overview
- 5 Setting Up the Development Environment
- 6 Creating Events and Broadcasting
- 7 Setup File Upload
- 8 Creating the Chat Controllers
- 9 Frontend Implementation
- 10 Running the Application
- 11 Conclusion
Introduction
In this guide, we’ll walk you through how to build a real-time chat app with Laravel and Pusher infrastructure. This tutorial will cover everything from basic messaging to advanced features like typing indicators, file sharing, private message room and push notifications, while maintaining production-ready code with proper testing, security, and deployment strategies.
What is Pusher?
Pusher is a hosted service that provides real-time messaging via WebSockets, simplifying the process of implementing real-time features in applications. It allows clients (such as browsers or mobile apps) to subscribe to channels and receive updates when events occur.
What is Laravel Echo?
Laravel Echo is a JavaScript library that facilitates subscribing to channels and listening to events broadcast by Laravel. It abstracts the complexity of working with WebSockets and seamlessly integrates with various broadcasting drivers, including Pusher.
What You’ll Build
Our chat application will include:
- Real-time messaging between multiple users
- Private and general chat rooms
- File and image sharing capabilities
- Typing indicators to show when users are composing messages
- Online/offline user status tracking
- Responsive design that works on desktop and mobile devices
Technical Requirements
Before starting this tutorial, ensure you have the following installed on your development machine:
- PHP 8.2 or higher
- Composer for PHP dependency management
- Node.js 18+ and npm for frontend asset compilation
- MySQL 8.0+ or MariaDB 10.3+ for database operations
- Redis 6.0+ (optional but recommended for session and queue management)
Project Architecture Overview
Our chat application uses a modern event-driven architecture with Laravel’s built-in features:
Backend Architecture (Laravel 11)
- MVC Pattern: Clean separation between Models (data), Views (presentation), and Controllers (logic)
- Events & Broadcasting: Real-time message delivery using Laravel’s broadcasting system
- Authentication: Secure user authentication with Laravel’s built-in auth system
- File Storage: Organized file uploads using Laravel’s filesystem
- Database Migrations: Version-controlled database schema changes
Frontend Architecture (Blade + Alpine.js)
- Blade Templates: Server-side rendering for fast initial page loads
- Alpine.js: Lightweight JavaScript framework for interactive components
- Laravel Echo: WebSocket client for real-time communication
- Tailwind CSS: Utility-first CSS framework for responsive design
Setting Up the Development Environment
Step 1: Create Laravel Project
First, create a new Laravel 11 project using Composer:
composer create-project laravel/laravel realtime-chat-laravel
cd realtime-chat-laravel
Step 2: Install Required Packages
Install the necessary packages for real-time functionality::
composer require pusher/pusher-php-server
composer require intervention/image-laravel
composer require laravel/breeze --dev
php artisan breeze:install blade
npm install --save-dev alpinejs
npm install --save laravel-echo pusher-js
These packages provide essential functionality
pusher/pusher-php-server
: Enables server-side broadcasting to WebSocket channelsintervention/image
-laravel: Handles image processing and optimization for uploaded filesalpinejs
: Lightweight JavaScript framework for reactive componentslaravel-echo
: Client-side library for receiving real-time broadcastspusher-js
: JavaScript client for Pusher WebSocket connections
Step 3: Configure Environment Variables
Create a Pusher account at pusher.com and get your app credentials. Update your .env
file with the actual Pusher credentials:
APP_NAME="RealTime Chat"
APP_ENV=local
APP_KEY=base64:your-app-key-here
APP_DEBUG=true
APP_TIMEZONE=UTC
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=realtime_chat
DB_USERNAME=your_username
DB_PASSWORD=your_password
BROADCAST_CONNECTION=pusher
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_KEY=YOUR_APP_KEY
PUSHER_APP_SECRET=YOUR_APP_SECRET
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER
Replace the placeholders with our actual Pusher app credentials.
Step 4: Database Migration Setup
Create the necessary database migrations:
php artisan make:migration create_chat_rooms_table
php artisan make:migration create_chat_room_users_table
php artisan make:migration create_messages_table
php artisan make:migration create_message_attachments_table
php artisan make:migration add_online_status_to_users_table
Migration for chat_rooms
table:
<?php
...
public function up(): void
{
Schema::create('chat_rooms', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->enum('type', ['general', 'private', 'group'])->default('group');
$table->boolean('is_active')->default(true);
$table->foreignId('created_by')->constrained('users')->onDelete('cascade');
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
$table->index(['type', 'is_active']);
$table->index(['created_by', 'type']);
});
}
...
};
This migration creates the chat_rooms
table that stores information about different types of chat rooms. The type field distinguishes between general
, private
, and group
chats, while last_message_at
helps sort rooms by activity.
Migration for chat_room_users
:
Create the chat room users table migration:
<?php
...
public function up(): void
{
Schema::create('chat_room_users', function (Blueprint $table) {
$table->id();
$table->foreignId('chat_room_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamp('joined_at')->useCurrent();
$table->timestamp('last_read_at')->nullable();
$table->boolean('is_admin')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->unique(['chat_room_id', 'user_id']);
$table->index(['user_id', 'is_active']);
$table->index(['chat_room_id', 'is_admin']);
});
}
...
};
The chat_room_users
table manages the many-to-many relationship between users and chat rooms. The last_read_at
field tracks unread messages while is_admin
allows for room moderation features.
Migration for messages
table:
Create the messages table migration:
<?php
...
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('chat_room_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('content')->nullable();
$table->enum('type', ['text', 'image', 'file', 'system'])->default('text');
$table->json('metadata')->nullable();
$table->timestamp('edited_at')->nullable();
$table->boolean('is_deleted')->default(false);
$table->timestamps();
$table->index(['chat_room_id', 'created_at']);
$table->index(['user_id', 'created_at']);
$table->index(['is_deleted', 'created_at']);
});
}
...
};
The messages table stores all chat messages with support for different types including text, images, files, and system messages. The metadata
field stores additional information like file sizes while is_deleted
provides soft delete functionality.
Migration for message_attachments
table:
Create the message attachments table migration:
<?php
...
public function up(): void
{
Schema::create('message_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('message_id')->constrained()->onDelete('cascade');
$table->string('filename');
$table->string('original_filename');
$table->string('path');
$table->bigInteger('size');
$table->string('mime_type');
$table->string('file_hash')->nullable();
$table->timestamps();
$table->index(['message_id']);
$table->index(['mime_type']);
});
}
...
};
The message_attachments table handles file uploads with detailed metadata including file hashes for deduplication and security purposes.
Migration for online_status
table:
Create the online status to users table migration:
<?php
...
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_online')->default(false);
$table->timestamp('last_seen_at')->nullable();
$table->string('avatar')->nullable();
});
}
...
};
This migration adds online status tracking to the existing users table along with last activity timestamps and avatar storage for profile pictures.
Run the command below to create the table:
php artisan migrate
Step 5: Create Eloquent Models
Generate the Eloquent models for our application:
php artisan make:model ChatRoom
php artisan make:model ChatRoomUser
php artisan make:model Message
php artisan make:model MessageAttachment
ChatRoom
Model
Create the ChatRoom model:
<?php
// import
class ChatRoom extends Model
{
protected $fillable = [
'name',
'description',
'type',
'is_active',
'created_by',
'last_message_at'
];
protected $casts = [
'is_active' => 'boolean',
'last_message_at' => 'datetime'
];
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class, 'chat_room_users')
->withPivot(['joined_at', 'last_read_at', 'is_admin', 'is_active'])
->withTimestamps();
}
public function activeUsers(): BelongsToMany
{
return $this->users()->wherePivot('is_active', true);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class)->where('is_deleted', false);
}
public function latestMessage(): HasOne
{
return $this->hasOne(Message::class)->latestOfMany()->where('is_deleted', false);
}
public function isGeneral(): bool
{
return $this->type === 'general';
}
public function isPrivate(): bool
{
return $this->type === 'private';
}
public function isGroup(): bool
{
return $this->type === 'group';
}
public function getDisplayName(): string
{
if ($this->isPrivate()) {
return 'Private Chat';
}
return $this->name;
}
}
The ChatRoom
model defines relationships between chat rooms, users, and messages. The helper methods provide convenient ways to check room types and get appropriate display names for different chat room contexts.
ChatRoomUser
Model
Create the ChatRoomUser
model:
<?php
//import
class ChatRoomUser extends Model
{
protected $fillable = [
'chat_room_id',
'user_id',
'joined_at',
'last_read_at',
'is_admin',
'is_active'
];
protected $casts = [
'joined_at' => 'datetime',
'last_read_at' => 'datetime',
'is_admin' => 'boolean',
'is_active' => 'boolean'
];
public function chatRoom(): BelongsTo
{
return $this->belongsTo(ChatRoom::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function hasUnreadMessages(): bool
{
if (!$this->last_read_at) {
return true;
}
return $this->chatRoom->messages()
->where('created_at', '>', $this->last_read_at)
->where('user_id', '!=', $this->user_id)
->exists();
}
public function getUnreadMessagesCount(): int
{
if (!$this->last_read_at) {
return $this->chatRoom->messages()
->where('user_id', '!=', $this->user_id)
->count();
}
return $this->chatRoom->messages()
->where('created_at', '>', $this->last_read_at)
->where('user_id', '!=', $this->user_id)
->count();
}
public function markAsRead(): void
{
$this->update(['last_read_at' => now()]);
}
public function isAdmin(): bool
{
return $this->is_admin;
}
public function makeAdmin(): void
{
$this->update(['is_admin' => true]);
}
public function removeAdmin(): void
{
$this->update(['is_admin' => false]);
}
public function activate(): void
{
$this->update(['is_active' => true]);
}
public function deactivate(): void
{
$this->update(['is_active' => false]);
}
}
The ChatRoomUser
model manages the pivot relationship between users and chat rooms. It includes comprehensive methods for tracking unread messages, managing admin permissions, handling read receipts, and controlling active participation status.
Message
Model:
Create the Message
model:
<?php
//import
class Message extends Model
{
protected $fillable = [
'chat_room_id',
'user_id',
'content',
'type',
'metadata',
'edited_at',
'is_deleted'
];
protected $casts = [
'metadata' => 'array',
'edited_at' => 'datetime',
'is_deleted' => 'boolean'
];
public function chatRoom(): BelongsTo
{
return $this->belongsTo(ChatRoom::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function attachments(): HasMany
{
return $this->hasMany(MessageAttachment::class);
}
public function isEdited(): bool
{
return !is_null($this->edited_at);
}
public function isText(): bool
{
return $this->type === 'text';
}
public function isImage(): bool
{
return $this->type === 'image';
}
public function isFile(): bool
{
return $this->type === 'file';
}
public function isSystem(): bool
{
return $this->type === 'system';
}
public function markAsDeleted(): void
{
$this->update(['is_deleted' => true]);
}
public function restore(): void
{
$this->update(['is_deleted' => false]);
}
public function getDisplayContent(): string
{
if ($this->is_deleted) {
return 'This message was deleted';
}
if ($this->isSystem()) {
return $this->content;
}
if ($this->isFile() || $this->isImage()) {
return $this->metadata['original_filename'] ?? 'File attachment';
}
return $this->content;
}
}
The Message
model handles different message types and includes helper methods to determine message characteristics. The soft delete functionality allows messages to be hidden without permanent removal while maintaining chat history integrity.
MessageAttachment
Model
Create the MessageAttachment
model:
<?php
//import
class MessageAttachment extends Model
{
protected $fillable = [
'message_id',
'filename',
'original_filename',
'path',
'size',
'mime_type',
'file_hash'
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
public function getFileSizeFormatted(): string
{
$bytes = $this->size;
$units = ['B', 'KB', 'MB', 'GB'];
for ($i = 0; $bytes > 1024; $i++) {
$bytes /= 1024;
}
return round($bytes, 2) . ' ' . $units[$i];
}
public function isImage(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function isPdf(): bool
{
return $this->mime_type === 'application/pdf';
}
public function isDocument(): bool
{
return in_array($this->mime_type, [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain'
]);
}
public function getFullPath(): string
{
return Storage::disk('public')->path($this->path);
}
public function getUrl(): string
{
return Storage::disk('public')->url($this->path);
}
public function getThumbnailUrl(): string
{
if ($this->isImage()) {
$thumbnailPath = str_replace('.', '_thumb.', $this->path);
if (Storage::disk('public')->exists($thumbnailPath)) {
return Storage::disk('public')->url($thumbnailPath);
}
}
return $this->getUrl();
}
}
The MessageAttachment
model handles file metadata and provides utility methods for file handling. It includes methods for formatted file sizes, file type detection, and URL generation for both original files and thumbnails.
Step 6: Update User
Model
Update the User model to include chat-specific relationships:
<?php
// import
class User extends Authenticatable
{
use Notifiable;
protected $fillable = [
'name',
'email',
'password',
'avatar',
'is_online',
'last_seen_at'
];
protected $hidden = [
'password',
'remember_token',
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_online' => 'boolean',
'last_seen_at' => 'datetime'
];
public function chatRooms(): BelongsToMany
{
return $this->belongsToMany(ChatRoom::class, 'chat_room_users')
->withPivot(['joined_at', 'last_read_at', 'is_admin', 'is_active'])
->withTimestamps();
}
public function activeChatRooms(): BelongsToMany
{
return $this->chatRooms()->wherePivot('is_active', true);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
public function isOnline(): bool
{
return $this->is_online && $this->last_seen_at && $this->last_seen_at->gt(now()->subMinutes(5));
}
public function setOnline(): void
{
$this->update([
'is_online' => true,
'last_seen_at' => now()
]);
}
public function setOffline(): void
{
$this->update([
'is_online' => false,
'last_seen_at' => now()
]);
}
public function getInitials(): string
{
$names = explode(' ', $this->name);
$initials = '';
foreach ($names as $name) {
$initials .= strtoupper(substr($name, 0, 1));
}
return substr($initials, 0, 2);
}
public function getAvatarUrl(): string
{
if ($this->avatar) {
return asset('storage/' . $this->avatar);
}
return 'https://ui-avatars.com/api/?name=' . urlencode($this->name) . '&background=3B82F6&color=ffffff';
}
}
The User model includes relationships for chat functionality. The online status methods allow tracking user presence while the helper methods provide convenient ways to generate avatars and user initials for the interface.
Step 7: Database Seeders
Run the command below to create GeneralChatRoomSeeder
:
php artisan make:seeder GeneralChatRoomSeeder
Create seeders to populate initial data:
<?php
...
public function run(): void
{
$adminUser = User::where('email', 'admin@singaporechat.com')->first();
if (!$adminUser) {
$adminUser = User::create([
'name' => 'Singapore Chat Admin',
'email' => 'admin@singaporechat.com',
'password' => bcrypt('password'),
'email_verified_at' => now(),
]);
}
$generalRoom = ChatRoom::firstOrCreate(
['type' => 'general', 'name' => 'General Chat'],
[
'description' => 'Welcome to Singapore Chat! This is where everyone can chat together.',
'is_active' => true,
'created_by' => $adminUser->id,
'last_message_at' => now()
]
);
$allUsers = User::all();
foreach ($allUsers as $user) {
if (!$generalRoom->users()->where('user_id', $user->id)->exists()) {
$generalRoom->users()->attach($user->id, [
'joined_at' => now(),
'is_admin' => $user->id === $adminUser->id,
'is_active' => true
]);
}
}
$generalRoom->messages()->create([
'user_id' => $adminUser->id,
'content' => 'Welcome to Singapore Chat! Feel free to introduce yourself and start chatting.',
'type' => 'system'
]);
}
}
This seeder creates the general chat room and ensures all users are automatically added to it. The admin user is created if it doesn’t exist and receives admin privileges in the general room.
Update the DatabaseSeeder:
class DatabaseSeeder extends Seeder
{
public function run(): void
{
$this->call([
GeneralChatRoomSeeder::class,
]);
}
}
Run the seeders:
php artisan db:seed
Creating Events and Broadcasting
Step 8: Broadcasting Configuration
By default, the broadcasting is not enabled in Laravel v11 applications. We may enable broadcasting using the install:broadcasting
Artisan command:
php artisan install:broadcasting
This command creates the config/broadcasting.php
configuration file and the routes/channels.php
file for managing broadcast authorization routes.
In our config/broadcasting.php
file, update the connections
array to include the Pusher connection:
<?php
return [
'default' => env('BROADCAST_CONNECTION', 'pusher'),
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusherapp.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => true,
],
'client_options' => [],
],
],
];
This configuration sets up Pusher as your broadcasting driver and ensures secure WebSocket connections using TLS encryption.
Step 9: Real-Time Events
Create events for broadcasting real-time updates:
php artisan make:event MessageSent
php artisan make:event UserTyping
php artisan make:event UserStatusChanged
MessageSent
Event
Create the MessageSent event:
<?php
//import
class MessageSent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $message;
public function __construct(Message $message)
{
$this->message = $message->load('user', 'attachments');
}
public function broadcastOn(): array
{
return [
new PresenceChannel('chat-room.' . $this->message->chat_room_id),
];
}
public function broadcastWith(): array
{
return [
'message' => [
'id' => $this->message->id,
'content' => $this->message->getDisplayContent(),
'type' => $this->message->type,
'metadata' => $this->message->metadata,
'created_at' => $this->message->created_at->toISOString(),
'edited_at' => $this->message->edited_at?->toISOString(),
'user' => [
'id' => $this->message->user->id,
'name' => $this->message->user->name,
'avatar' => $this->message->user->getAvatarUrl(),
'initials' => $this->message->user->getInitials()
],
'attachments' => $this->message->attachments->map(function ($attachment) {
return [
'id' => $attachment->id,
'filename' => $attachment->original_filename,
'size' => $attachment->getFileSizeFormatted(),
'url' => $attachment->getUrl(),
'thumbnail_url' => $attachment->getThumbnailUrl(),
'is_image' => $attachment->isImage()
];
})
]
];
}
}
The MessageSent event broadcasts new messages to all chat room participants using presence channels. It includes comprehensive message data with user information and attachment details for a rich real-time experience.
UserTyping
Event
Create the UserTyping
event:
<?php
//import
class UserTyping implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $user;
public $chatRoom;
public $typing;
public function __construct(User $user, ChatRoom $chatRoom, bool $typing)
{
$this->user = $user;
$this->chatRoom = $chatRoom;
$this->typing = $typing;
}
public function broadcastOn(): array
{
return [
new PresenceChannel('chat-room.' . $this->chatRoom->id),
];
}
public function broadcastWith(): array
{
return [
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
'initials' => $this->user->getInitials()
],
'typing' => $this->typing,
'chat_room_id' => $this->chatRoom->id
];
}
}
The UserTyping
event shows typing indicators when users compose messages. It broadcasts to the specific chat room channel and includes user identification information for displaying appropriate typing notifications.
UserStatusChanged
Event
Create the UserStatusChanged
event:
<?php
//import
class UserStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $user;
public function __construct(User $user)
{
$this->user = $user;
}
public function broadcastOn(): array
{
return [
new Channel('users-status'),
];
}
public function broadcastWith(): array
{
return [
'user' => [
'id' => $this->user->id,
'name' => $this->user->name,
'is_online' => $this->user->isOnline(),
'last_seen_at' => $this->user->last_seen_at?->toISOString(),
'avatar' => $this->user->getAvatarUrl(),
'initials' => $this->user->getInitials()
]
];
}
}
The UserStatusChanged event broadcasts when users go online or offline. It uses a public channel since online status is generally visible to all users in a chat application.
Setup File Upload
Add the storage configuration update:
php artisan storage:link
This command will create storage symlink for public file access. Next we create a attachments directory in public folder.
mkdir -p storage/app/public/chat-attachments
Then we grant the permission to the directory:
chmod -R 755 storage/app/public/chat-attachments
Next, we update the file upload configuration in the .env
file:
FILESYSTEM_DISK=public
MAX_UPLOAD_SIZE=10240
ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,pdf,doc,docx,txt,zip,rar

Creating the Chat Controllers
Step 10: Main Chat Controller
Create the primary controller for handling chat operations:
php artisan make:controller ChatController
ChatController
Controller:
class ChatController extends Controller
{
public function index()
{
$user = Auth::user();
$chatRooms = $user->activeChatRooms()
->with(['latestMessage.user', 'users'])
->orderByDesc('last_message_at')
->get();
$generalRoom = $chatRooms->where('type', 'general')->first();
$allUsers = User::where('id', '!=', $user->id)
->select(['id', 'name', 'is_online', 'last_seen_at', 'avatar'])
->orderBy('is_online', 'desc')
->orderBy('name')
->get();
return view('chat.index', compact('chatRooms', 'generalRoom', 'allUsers', 'user'));
}
public function showRoom(ChatRoom $chatRoom)
{
$user = Auth::user();
if (!$chatRoom->users()->where('user_id', $user->id)->where('is_active', true)->exists()) {
abort(403, 'You do not have access to this chat room');
}
$messages = $chatRoom->messages()
->with(['user', 'attachments'])
->orderBy('created_at')
->take(50)
->get();
$chatRoom->users()
->where('user_id', $user->id)
->update(['last_read_at' => now()]);
return response()->json([
'room' => [
'id' => $chatRoom->id,
'name' => $chatRoom->getDisplayName(),
'type' => $chatRoom->type
],
'messages' => $messages->map(function ($message) {
return [
'id' => $message->id,
'content' => $message->getDisplayContent(),
'type' => $message->type,
'metadata' => $message->metadata,
'created_at' => $message->created_at->format('Y-m-d H:i:s'),
'user' => [
'id' => $message->user->id,
'name' => $message->user->name,
'avatar' => $message->user->getAvatarUrl(),
'initials' => $message->user->getInitials()
],
'attachments' => $message->attachments->map(function ($attachment) {
return [
'id' => $attachment->id,
'filename' => $attachment->original_filename,
'size' => $attachment->getFileSizeFormatted(),
'url' => $attachment->getUrl(),
'thumbnail_url' => $attachment->getThumbnailUrl(),
'is_image' => $attachment->isImage()
];
})
];
})
]);
}
public function sendMessage(Request $request, ChatRoom $chatRoom)
{
$request->validate([
'content' => 'required_without:attachment|string|max:1000',
'attachment' => 'nullable|file|max:10240', // 10MB max
]);
$user = Auth::user();
if (!$chatRoom->users()->where('user_id', $user->id)->where('is_active', true)->exists()) {
abort(403, 'You do not have access to this chat room');
}
$messageType = 'text';
$content = $request->content;
$metadata = null;
if ($request->hasFile('attachment')) {
$file = $request->file('attachment');
$messageType = $this->getFileMessageType($file);
$filename = Str::uuid() . '.' . $file->getClientOriginalExtension();
$path = "chat-attachments/{$chatRoom->id}/" . $filename;
Storage::disk('public')->put($path, $file->getContent());
if ($messageType === 'image') {
$this->createImageThumbnail($file, $path);
}
$metadata = [
'original_filename' => $file->getClientOriginalName(),
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType()
];
$content = $content ?: "Shared a file: " . $file->getClientOriginalName();
}
$message = $chatRoom->messages()->create([
'user_id' => $user->id,
'content' => $content,
'type' => $messageType,
'metadata' => $metadata
]);
if ($request->hasFile('attachment')) {
$file = $request->file('attachment');
MessageAttachment::create([
'message_id' => $message->id,
'filename' => $filename,
'original_filename' => $file->getClientOriginalName(),
'path' => $path,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'file_hash' => hash_file('sha256', $file->getPathname())
]);
}
$message->load('user', 'attachments');
$chatRoom->update(['last_message_at' => now()]);
broadcast(new MessageSent($message));
return response()->json([
'message' => 'Message sent successfully',
'message_id' => $message->id
]);
}
public function downloadAttachment(MessageAttachment $attachment)
{
$user = Auth::user();
if (!$attachment->message->chatRoom->users()->where('user_id', $user->id)->where('is_active', true)->exists()) {
abort(403, 'You do not have access to this file');
}
if (!Storage::disk('public')->exists($attachment->path)) {
abort(404, 'File not found');
}
return Storage::disk('public')->download($attachment->path, $attachment->original_filename);
}
private function getFileMessageType($file): string
{
$mimeType = $file->getMimeType();
if (str_starts_with($mimeType, 'image/')) {
return 'image';
}
return 'file';
}
private function createImageThumbnail($file, $path): void
{
try {
$thumbnailPath = str_replace('.', '_thumb.', $path);
$image = Image::read($file->getPathname());
$image->cover(300, 300);
Storage::disk('public')->put($thumbnailPath, $image->encode());
} catch (\Exception $e) {
\Log::warning('Failed to create thumbnail: ' . $e->getMessage());
}
}
public function sendTyping(Request $request, ChatRoom $chatRoom)
{
$user = Auth::user();
if (!$chatRoom->users()->where('user_id', $user->id)->where('is_active', true)->exists()) {
return response()->json(['error' => 'Unauthorized'], 403);
}
broadcast(new UserTyping($user, $chatRoom, $request->boolean('typing')));
return response()->json(['status' => 'ok']);
}
public function createPrivateRoom(User $otherUser)
{
$currentUser = Auth::user();
if ($currentUser->id === $otherUser->id) {
return response()->json(['error' => 'Cannot create private room with yourself'], 400);
}
$existingRoom = ChatRoom::where('type', 'private')
->whereHas('users', function ($query) use ($currentUser) {
$query->where('user_id', $currentUser->id)->where('is_active', true);
})
->whereHas('users', function ($query) use ($otherUser) {
$query->where('user_id', $otherUser->id)->where('is_active', true);
})
->whereRaw('(SELECT COUNT(*) FROM chat_room_users WHERE chat_room_id = chat_rooms.id AND is_active = 1) = 2')
->first();
if ($existingRoom) {
return response()->json([
'room' => [
'id' => $existingRoom->id,
'name' => $otherUser->name,
'type' => 'private'
]
]);
}
$privateRoom = ChatRoom::create([
'name' => $currentUser->name . ' & ' . $otherUser->name,
'type' => 'private',
'created_by' => $currentUser->id,
'last_message_at' => now()
]);
$privateRoom->users()->attach([
$currentUser->id => [
'joined_at' => now(),
'is_active' => true
],
$otherUser->id => [
'joined_at' => now(),
'is_active' => true
]
]);
return response()->json([
'room' => [
'id' => $privateRoom->id,
'name' => $otherUser->name,
'type' => 'private'
]
]);
}
}
The ChatController
runs the main parts of the chat. The index
method loads the chat rooms a user is in, shows the latest messages, and lists all other users available to chat with.
The showRoom
method opens a chat room, checks if the user has access, and returns recent messages along with who sent them and any attachments.
The sendMessage
method saves a new message in the room and broadcasts it so everyone in the room can see it right away. Inside this method, we also handle the attachments.
The createPrivateRoom
method handles one-to-one chats. It looks for an existing private room between two users, and if there isn’t one, it creates a new room and adds both users.
Step 11: Middleware for Online Status
We now create a middleware to track when a user is online.
php artisan make:middleware UpdateUserOnlineStatus
In the middleware, we check if the user is logged in. If yes, we update their status to online. If they were offline before, we also broadcast an event so that others can see the status change.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Events\UserStatusChanged;
use Symfony\Component\HttpFoundation\Response;
class UpdateUserOnlineStatus
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
$user = Auth::user();
$wasOffline = !$user->isOnline();
$user->setOnline();
if ($wasOffline) {
broadcast(new UserStatusChanged($user));
}
}
return $next($request);
}
}
Next, register the middleware in bootstrap/app.php
so it runs with every web request:
use App\Http\Middleware\UpdateUserOnlineStatus;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
UpdateUserOnlineStatus::class,
]);
})
->create();
This way, each time the user interacts with the app, their online status is kept fresh.
Step 12: Update AuthenticatedSessionController
Next, we make sure users are always added into the General Room once they log in. This guarantees every user has a common place to chat.
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
$user = Auth::user();
$this->ensureUserInGeneralRoom($user);
return redirect()->intended(route('chat.index'));
}
private function ensureUserInGeneralRoom($user)
{
$generalRoom = ChatRoom::firstOrCreate([
'type' => 'general'
], [
'name' => 'General Chat',
'created_by' => 1,
'last_message_at' => now()
]);
if (!$generalRoom->users()->where('user_id', $user->id)->exists()) {
$generalRoom->users()->attach($user->id, [
'joined_at' => now(),
'is_active' => true,
'last_read_at' => now()
]);
}
}
public function destroy(Request $request): RedirectResponse
{
$user = Auth::user();
if ($user) {
$user->setOffline();
}
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
Here the key change is the ensureUserInGeneralRoom
method, which checks if the user is in the general chat. If not, it adds them automatically. This ensures users are automatically taken to the chat interface after logging in and properly marked as offline when logging out.
Step 13: Chat Routes
Next, we add chat routes in routes/web.php
. These handle loading chat rooms, sending messages, typing indicators, and private chats:
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\ChatController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::get('/dashboard', function () {
return redirect('/chat');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/chat', [ChatController::class, 'index'])->name('chat.index');
Route::get('/chat/room/{chatRoom}', [ChatController::class, 'showRoom'])->name('chat.room');
Route::post('/chat/room/{chatRoom}/message', [ChatController::class, 'sendMessage'])->name('chat.message');
Route::post('/chat/room/{chatRoom}/typing', [ChatController::class, 'sendTyping'])->name('chat.typing');
Route::post('/chat/private/{otherUser}', [ChatController::class, 'createPrivateRoom'])->name('chat.private');
Route::get('/chat/attachment/{attachment}/download', [ChatController::class, 'downloadAttachment'])->name('chat.attachment.download');
});
require __DIR__.'/auth.php';
The root route redirects authenticated users to the chat interface. All chat-related routes are protected by the auth middleware, ensuring only logged-in users can access them.
Broadcast Channels
We also need to define our broadcast channels in routes/channels.php
. This makes sure only valid users can listen to chat rooms and status updates:
use Illuminate\Support\Facades\Broadcast;
use App\Models\ChatRoom;
Broadcast::channel('users-status', function ($user) {
return $user ? ['id' => $user->id, 'name' => $user->name] : false;
});
Broadcast::channel('chat-room.{chatRoomId}', function ($user, $chatRoomId) {
$chatRoom = ChatRoom::find($chatRoomId);
if (!$chatRoom) {
return false;
}
$userInRoom = $chatRoom->users()->where('user_id', $user->id)->where('is_active', true)->exists();
if ($userInRoom) {
return [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->getAvatarUrl(),
'initials' => $user->getInitials(),
'is_online' => $user->isOnline()
];
}
return false;
});
The channels file defines WebSocket channel authorization. This specific channel authorizes users to join conversation-specific channels only if they’re participants in that conversation. When authorized, it returns user information that will be available to other channel members for presence features.
Frontend Implementation
Now that the backend is ready, we set up the frontend to make the chat work in real time. We use Alpine.js to manage the UI state and Laravel Echo with Pusher to handle live updates.
This script controls everything on the browser: loading rooms, showing messages, sending messages, and updating online users.
Step 1: Real-Time Broadcasting Script
Create resources/js/app.js
and add the following code:
import axios from 'axios';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import Alpine from 'alpinejs';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
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,
encrypted: true,
forceTLS: false
});
Alpine.data('chatApp', () => ({
currentRoom: null,
_messages: [],
messageInput: '',
users: [],
typingUsers: new Set(),
isTyping: false,
typingTimeout: null,
onlineUsers: new Set(),
roomChannel: null,
previousRoomId: null,
messageCounter: 0,
selectedFile: null,
filePreview: null,
showFileModal: false,
uploadProgress: 0,
isUploading: false,
get messages() {
if (!Array.isArray(this._messages)) {
this._messages = [];
}
return this._messages;
},
set messages(value) {
this._messages = Array.isArray(value) ? value : [];
},
init() {
this.messages = [];
this.typingUsers = new Set();
setTimeout(() => {
this.loadGeneralRoom();
this.setupUserStatusChannel();
}, 100);
},
loadGeneralRoom() {
const generalRoom = document.querySelector('[data-general-room]');
if (generalRoom) {
const roomId = generalRoom.dataset.generalRoom;
this.loadRoom(roomId, 'General Chat');
} else {
this.currentRoom = { id: 1, name: 'General Chat' };
this.messages = [];
this.setupRoomChannel(1);
}
},
setupUserStatusChannel() {
try {
window.Echo.channel('users-status')
.listen('UserStatusChanged', (e) => {
this.updateUserStatus(e.user);
});
} catch (error) {
console.error('Error setting up user status channel:', error);
}
},
updateUserStatus(user) {
if (user.is_online) {
this.onlineUsers.add(user.id);
} else {
this.onlineUsers.delete(user.id);
}
const userElement = document.querySelector(`[data-user-id="${user.id}"]`);
if (userElement) {
const statusDot = userElement.querySelector('.status-dot');
if (statusDot) {
statusDot.className = user.is_online
? 'status-dot absolute -bottom-1 -right-1 w-3 h-3 bg-green-400 rounded-full border-2 border-white'
: 'status-dot absolute -bottom-1 -right-1 w-3 h-3 bg-gray-400 rounded-full border-2 border-white';
}
}
},
loadRoom(roomId, roomName) {
this.currentRoom = { id: parseInt(roomId), name: roomName };
this.typingUsers = new Set();
if (this.previousRoomId && this.roomChannel) {
try {
window.Echo.leaveChannel(`chat-room.${this.previousRoomId}`);
this.roomChannel = null;
} catch (err) {
console.warn('Error leaving previous room:', err);
}
}
this.previousRoomId = roomId;
fetch(`/chat/room/${roomId}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
this.messages = Array.isArray(data.messages) ? data.messages : [];
this.setupRoomChannel(roomId);
setTimeout(() => this.scrollToBottom(), 100);
})
.catch(error => {
console.error('Error loading room:', error);
this.messages = [];
this.setupRoomChannel(roomId);
});
},
setupRoomChannel(roomId) {
if (this.roomChannel) {
try {
this.roomChannel.stopListening('MessageSent');
this.roomChannel.stopListening('UserTyping');
window.Echo.leaveChannel(`chat-room.${roomId}`);
this.roomChannel = null;
} catch (err) {
console.warn('Error cleaning up existing channel:', err);
}
}
try {
this.roomChannel = window.Echo.join(`chat-room.${roomId}`)
.here((users) => {
users.forEach(user => this.onlineUsers.add(user.id));
})
.joining((user) => {
this.onlineUsers.add(user.id);
this.updateUserStatus({ ...user, is_online: true });
})
.leaving((user) => {
this.onlineUsers.delete(user.id);
this.updateUserStatus({ ...user, is_online: false });
this.typingUsers.delete(user.name);
})
.listen('MessageSent', (e) => {
if (e.message.user.id !== window.currentUserId) {
const currentMessages = [...this.messages];
e.message.uniqueKey = ++this.messageCounter;
currentMessages.push(e.message);
this.messages = currentMessages;
setTimeout(() => this.scrollToBottom(), 50);
}
})
.listen('UserTyping', (e) => {
if (e.user.id === window.currentUserId) {
return;
}
const userName = e.user.name;
const newTypingUsers = new Set(this.typingUsers);
if (e.typing) {
newTypingUsers.add(userName);
} else {
newTypingUsers.delete(userName);
}
this.typingUsers = newTypingUsers;
});
} catch (error) {
console.error('Error setting up room channel:', error);
}
},
sendMessage() {
if ((!this.messageInput.trim() && !this.selectedFile) || !this.currentRoom) return;
const message = this.messageInput.trim();
const hasFile = this.selectedFile !== null;
const tempMessage = {
id: 'temp-' + Date.now(),
content: message || (hasFile ? `Shared a file: ${this.selectedFile.name}` : ''),
type: hasFile ? (this.selectedFile.type.startsWith('image/') ? 'image' : 'file') : 'text',
user: {
id: window.currentUserId,
name: document.querySelector('meta[name="user-name"]')?.content || 'You',
avatar: document.querySelector('meta[name="user-avatar"]')?.content || 'https://ui-avatars.com/api/?name=' + encodeURIComponent(document.querySelector('meta[name="user-name"]')?.content || 'User') + '&background=3b82f6&color=fff'
},
created_at: new Date().toISOString(),
uniqueKey: ++this.messageCounter,
attachments: hasFile ? [{
id: 'temp',
filename: this.selectedFile.name,
size: this.formatFileSize(this.selectedFile.size),
url: this.filePreview,
is_image: this.selectedFile.type.startsWith('image/'),
uploading: true
}] : []
};
const currentMessages = [...this.messages];
currentMessages.push(tempMessage);
this.messages = currentMessages;
this.messageInput = '';
this.stopTyping();
this.showFileModal = false;
setTimeout(() => this.scrollToBottom(), 50);
const formData = new FormData();
if (message) formData.append('content', message);
if (hasFile) formData.append('attachment', this.selectedFile);
this.isUploading = true;
this.uploadProgress = 0;
fetch(`/chat/room/${this.currentRoom.id}/message`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: formData
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
const messageIndex = this.messages.findIndex(msg => msg.id === tempMessage.id);
if (messageIndex !== -1) {
const updatedMessages = [...this.messages];
updatedMessages[messageIndex] = {
...tempMessage,
id: data.message_id,
attachments: tempMessage.attachments.map(att => ({...att, uploading: false}))
};
this.messages = updatedMessages;
}
})
.catch(error => {
console.error('Error sending message:', error);
const failedMessageIndex = this.messages.findIndex(msg => msg.id === tempMessage.id);
if (failedMessageIndex !== -1) {
const updatedMessages = [...this.messages];
updatedMessages.splice(failedMessageIndex, 1);
this.messages = updatedMessages;
}
this.messageInput = message;
})
.finally(() => {
this.isUploading = false;
this.uploadProgress = 0;
this.clearSelectedFile();
});
},
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert('File size must be less than 10MB');
return;
}
this.selectedFile = file;
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
this.filePreview = e.target.result;
this.showFileModal = true;
};
reader.readAsDataURL(file);
} else {
this.filePreview = null;
this.showFileModal = true;
}
},
clearSelectedFile() {
this.selectedFile = null;
this.filePreview = null;
this.showFileModal = false;
const fileInput = document.getElementById('file-input');
if (fileInput) fileInput.value = '';
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
downloadFile(attachmentUrl, filename) {
const link = document.createElement('a');
link.href = attachmentUrl;
link.download = filename;
link.click();
},
handleTyping() {
if (!this.isTyping && this.currentRoom) {
this.isTyping = true;
this.sendTypingStatus(true);
}
clearTimeout(this.typingTimeout);
this.typingTimeout = setTimeout(() => this.stopTyping(), 1500);
},
stopTyping() {
if (this.isTyping && this.currentRoom) {
this.isTyping = false;
this.sendTypingStatus(false);
}
clearTimeout(this.typingTimeout);
},
sendTypingStatus(typing) {
if (!this.currentRoom) return;
fetch(`/chat/room/${this.currentRoom.id}/typing`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ typing })
})
.catch(error => console.error('Error sending typing status:', error));
},
startPrivateChat(userId, userName) {
fetch(`/chat/private/${userId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
}
})
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
this.loadRoom(data.room.id, data.room.name);
})
.catch(error => {
console.error('Error creating private room:', error);
});
},
scrollToBottom() {
const container = document.getElementById('messages-container');
if (container) {
container.scrollTop = container.scrollHeight;
}
},
formatTime(timestamp) {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
},
getTypingText() {
const users = Array.from(this.typingUsers);
if (!users.length) return '';
if (users.length === 1) return `${users[0]} is typing...`;
if (users.length === 2) return `${users[0]} and ${users[1]} are typing...`;
return `${users[0]} and ${users.length - 1} others are typing...`;
}
}));
window.Alpine = Alpine;
Alpine.start();
When the app starts, it automatically joins the general chat room and subscribes to the users-status
channel so that online and offline updates are shown in real time. Each time a user switches rooms, it leaves the old channel and joins the new one to listen for MessageSent
and UserTyping
events.
Messages are shown immediately in the UI, even before the server confirms, and later updated with the real database ID. Typing notifications are managed with small timeouts so they appear natural. Private chat creation is also handled here by sending a request to the backend and then opening the new room.
Step 2: Blade View
We create a Blade view for the chat page and bind it to the Alpine.js chatApp
component. This view includes the chat room list, message display container, input box, typing notifications, and online user indicators.
We start by defining the main view, which wraps the chat component in the standard x-app-layout
:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Singapore Chat') }}
</h2>
</x-slot>
<div class="h-screen flex flex-col">
<div class="flex-shrink-0"></div>
<div class="flex-1 overflow-hidden">
<div class="h-full bg-white">
<x-chat-layout
:user="$user"
:generalRoom="$generalRoom"
:allUsers="$allUsers"
/>
</div>
</div>
</div>
</x-app-layout>
Next, we create the chat-layout
component. This component defines the sidebar for chat rooms and users and the main area where messages appear. It binds to the Alpine.js chatApp()
instance and updates dynamically as messages are sent or received.
Create resources/views/components/chat-layout.blade.php
:
<div class="h-full" x-data="chatApp()" x-init="init()">
<div class="flex h-full">
{{-- Sidebar remains the same --}}
<div class="w-80 bg-gray-50 shadow-lg flex flex-col border-r border-gray-200">
<div class="p-4 border-b bg-white flex-shrink-0">
<div class="flex items-center justify-between mb-2">
<h1 class="text-lg font-semibold text-gray-800">Chat Rooms</h1>
<div class="flex items-center space-x-2">
<img src="{{ $user->getAvatarUrl() }}" alt="{{ $user->name }}" class="w-6 h-6 rounded-full">
<span class="text-sm text-gray-600 hidden sm:block">{{ $user->name }}</span>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div class="p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Rooms</h3>
@if($generalRoom)
<div class="space-y-2 mb-4">
<button
@click="loadRoom({{ $generalRoom->id }}, 'General Chat')"
data-general-room="{{ $generalRoom->id }}"
class="w-full text-left p-3 rounded-lg hover:bg-white border transition-colors duration-200"
:class="currentRoom?.id == {{ $generalRoom->id }} ? 'bg-blue-50 border-blue-200' : 'border-gray-200'"
>
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
<span class="text-white font-semibold text-sm">#</span>
</div>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-gray-800 truncate">General Chat</h4>
<p class="text-xs text-gray-600 truncate">Public room for everyone</p>
</div>
</div>
</button>
</div>
@endif
</div>
{{-- Users List --}}
<div class="border-t p-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Users</h3>
<div class="space-y-1">
@foreach($allUsers as $otherUser)
<div data-user-id="{{ $otherUser->id }}"
class="flex items-center space-x-3 p-2 rounded-lg hover:bg-white cursor-pointer transition-colors duration-200"
@click="startPrivateChat({{ $otherUser->id }}, '{{ $otherUser->name }}')">
<div class="relative flex-shrink-0">
<img src="{{ $otherUser->getAvatarUrl() }}" alt="{{ $otherUser->name }}" class="w-8 h-8 rounded-full">
<div class="status-dot absolute -bottom-1 -right-1 w-3 h-3 {{ $otherUser->isOnline() ? 'bg-green-400' : 'bg-gray-400' }} rounded-full border-2 border-white"></div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-800 truncate">{{ $otherUser->name }}</h4>
<p class="text-xs text-gray-600 truncate">
{{ $otherUser->isOnline() ? 'Online' : 'Offline' }}
</p>
</div>
</div>
@endforeach
</div>
</div>
</div>
</div>
{{-- Chat Area --}}
<div class="flex-1 flex flex-col bg-white min-w-0">
<div class="p-4 border-b bg-gray-50 flex-shrink-0">
<h2 class="text-lg font-semibold text-gray-800 truncate" x-text="currentRoom ? currentRoom.name : 'Select a chat room'"></h2>
</div>
<div id="messages-container" class="flex-1 overflow-y-auto p-4 space-y-4" x-show="currentRoom" style="min-height: 0;">
<template x-for="(message, index) in messages" :key="'message_' + (message.uniqueKey || message.id || index) + '_' + index">
<div class="flex space-x-3" x-show="message && message.user">
<img :src="message.user?.avatar" :alt="message.user?.name" class="w-8 h-8 rounded-full flex-shrink-0">
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2">
<h4 class="font-medium text-gray-800" x-text="message.user?.name"></h4>
<span class="text-xs text-gray-500" x-text="formatTime(message.created_at)"></span>
</div>
<div class="mt-1">
<p class="text-gray-700 break-words" x-text="message.content" x-show="message.content"></p>
<!-- File Attachments -->
<template x-for="attachment in (message.attachments || [])">
<div class="mt-2 max-w-sm">
<!-- Image Attachment -->
<div x-show="attachment.is_image" class="relative">
<img :src="attachment.thumbnail_url || attachment.url"
:alt="attachment.filename"
class="rounded-lg cursor-pointer hover:opacity-90 transition-opacity max-w-full h-auto"
@click="window.open(attachment.url, '_blank')"
style="max-height: 300px;">
<div x-show="attachment.uploading" class="absolute inset-0 bg-black bg-opacity-50 rounded-lg flex items-center justify-center">
<div class="text-white text-sm">Uploading...</div>
</div>
</div>
<!-- File Attachment -->
<div x-show="!attachment.is_image"
class="bg-gray-100 rounded-lg p-3 border flex items-center space-x-3 hover:bg-gray-200 transition-colors cursor-pointer"
@click="downloadFile(attachment.url, attachment.filename)">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm8 2a1 1 0 000 2h3a1 1 0 100-2h-3zM4 8a1 1 0 000 2h3a1 1 0 000-2H4z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium text-gray-800 truncate" x-text="attachment.filename"></h4>
<p class="text-xs text-gray-600" x-text="attachment.size"></p>
</div>
<div x-show="attachment.uploading" class="text-xs text-blue-600">Uploading...</div>
<div x-show="!attachment.uploading" class="text-xs text-gray-500">Click to download</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<div x-show="getTypingText()" class="flex items-center space-x-2 text-gray-500 text-sm">
<div class="flex space-x-1">
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.1s"></div>
<div class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0.2s"></div>
</div>
<span x-text="getTypingText()"></span>
</div>
</div>
<!-- Message Input Area -->
<div class="p-4 border-t bg-white flex-shrink-0" x-show="currentRoom">
<!-- File Preview Modal -->
<div x-show="showFileModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="clearSelectedFile()">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold mb-4">Send File</h3>
<div x-show="filePreview" class="mb-4">
<img :src="filePreview" class="max-w-full h-auto rounded-lg" style="max-height: 200px;">
</div>
<div x-show="selectedFile" class="mb-4">
<p class="text-sm text-gray-600"><strong>File:</strong> <span x-text="selectedFile?.name"></span></p>
<p class="text-sm text-gray-600"><strong>Size:</strong> <span x-text="selectedFile ? formatFileSize(selectedFile.size) : ''"></span></p>
</div>
<input type="text" x-model="messageInput" placeholder="Add a message (optional)..."
class="w-full p-2 border border-gray-300 rounded-lg mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500">
<div class="flex justify-end space-x-2">
<button @click="clearSelectedFile()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Cancel</button>
<button @click="sendMessage()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">Send</button>
</div>
</div>
</div>
<!-- Message Input -->
<div class="flex space-x-2 items-end">
<input type="file" id="file-input" @change="handleFileSelect($event)" class="hidden" accept="*/*">
<button @click="document.getElementById('file-input').click()"
class="p-3 text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z"/>
</svg>
</button>
<input type="text" x-model="messageInput" @keyup.enter="sendMessage" @input="handleTyping" @blur="stopTyping"
placeholder="Type your message..."
class="flex-1 p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<button @click="sendMessage" :disabled="!messageInput.trim() && !selectedFile"
class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
Send
</button>
</div>
</div>
</div>
</div>
</div>
<script>
window.currentUserId = {{ $user->id }};
</script>
Running the Application
Next, we prepare the frontend for testing and make sure the environment is running. First, build the assets by running:
npm run build
This compiles the Alpine.js, Tailwind, and other frontend scripts into the public
directory so that Laravel can serve them. After the build is complete, start the Laravel development server using:
php artisan serve
By default, the application will be available at http://127.0.0.1:8000
. Open this URL in your browser to access the app.
Use Laravel’s built-in authentication pages to create users. Visit the register page, fill in the details, and create a new user. Then log in using that account.
Repeat this in another browser, incognito window, or a different device to simulate multiple users. This allows you to test sending messages, observing real-time updates, checking typing indicators, and verifying online/offline status for different users.

Conclusion
You can access the complete working code on GitHub.