Contents
- Prerequisite:
- Step 1: Install Laravel 11
- Step 2: Set Database Configuration
- Step 3: Install Laravel Breeze
- Step 4: Run the App
- Step 5: Install Laravel Reverb
- Step 6: Start the Reverb Server
- Step 7: Create an Event
- Step 8: Create A Private Channel
- Step 9: Create a Controller
- Step 10: Define Routes
- Step 11: Install PeerJs
- Step 12: Create Contacts Page And Update AuthenticatedLayout
- Step 13: Run The App
Want to build a video chat app but don’t know where to start? This tutorial will walk you through creating one step by step! Using Laravel 11 for the backend, React for the frontend, and WebRTC for real-time video streaming, you’ll have all the tools you need to make it happen.
We’ll also use Laravel Breeze for easy authentication, Laravel Reverb for live updates, and PeerJS for peer-to-peer connections. By the end, you’ll have a fully working video chat app that you can expand and customize.
Laravel is a free, open-source PHP Web Framework intended for the development of web applications following the MVC (Model-View-Controller) architectural pattern. Laravel is designed to make developing web apps faster and easier by using built-in features.
Laravel Breeze gives you a simple and minimal implementation of login, registration, password reset, email verification, and password confirmation which are part of Laravel’s authentication features.
React or also called React.js or Reactjs is a free and open-source JavaScript library used for building user interfaces(UI). It is one of the most popular JavaScript libraries for building the front end. React is created by Facebook and maintained by Facebook.
Laravel Reverb is a first-party WebSocket server for Laravel applications. It brings real-time communication between the client and server directly to your Laravel projects
WebRTC (Web Real-Time Communication) is an open-source project and technology standard that enables real-time audio, video, and data communication directly between web browsers and devices, without requiring plugins or external software.
PeerJS is a JavaScript library that simplifies the implementation of WebRTC peer-to-peer (P2P) connections. It provides an abstraction layer over the WebRTC APIs, making it easier to establish reliable connections for real-time communication without needing to handle the complexity of WebRTC directly.
Prerequisite:
- Composer
- PHP >= 8.2
- Node >= 18
Step 1: Install Laravel 11
First, select a folder you want Laravel to be installed then execute this command on Terminal or CMD to install Laravel 11:
Install via composer:
composer create-project laravel/laravel laravel-11-video-chat-app-react
Install via Laravel Installer:
laravel new laravel-11-video-chat-app-react
Step 2: Set Database Configuration
Open the .env file and set the database configuration:
.env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your database name(laravel_11_video_chat_app_react)
DB_USERNAME=your database username(root)
DB_PASSWORD=your database password(root)
Step 3: Install Laravel Breeze
Then install the Laravel Breeze package
composer require laravel/breeze --dev
After installing packages, run the artisan command breeze:install to publish the authentication views, routes, controller, and other resources to the project.
php artisan breeze:install
We will be choosing React with Inertia as a stack:
After the installation, run these commands to run the migration and compile the assets.
php artisan migrate
npm install
npm run dev
Step 4: Run the App
Run the laravel app:
php artisan serve
You can now navigate to the login and register URL.
http://localhost:8000/login
http://localhost:8000/register
Now register these 3 accounts, we will be using these accounts for the real-time chat.
http://localhost:8000/register
Step 5: Install Laravel Reverb
Install Laravel Reverb by running this command:
php artisan install:broadcasting
This command will set up Reverb with a sensible set of default configuration options.
Step 6: Start the Reverb Server
start the Reverb server with this command:
php artisan reverb:start
By default, the server runs on port 8080. You can specify a custom host or port using the --host
and --port
options if needed eg. php artisan reverb:start --host=127.0.0.1 --port=9000
.
Step 7: Create an Event
An event is a way to signal that something has happened within your application. It allows you to decouple various parts of your system by letting different parts respond to the same event in their own way. We will now create event classes named RequestVideoCall and RequestVideoCallStatus and we will use the ShouldBroadcastNow
interface to immediately broadcast an event instead of queuing it for later processing. This is especially useful when you need real-time updates without any delay.
php artisan make:event RequestVideoCall
php artisan make:event RequestVideoCallStatus
update RequestVideoCall.php
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RequestVideoCall implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("video-call.{$this->user->id}"),
];
}
}
update RequestVideoCallStatus.php
<?php
namespace App\Events;
use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RequestVideoCallStatus implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("video-call.{$this->user->id}"),
];
}
}
Step 8: Create A Private Channel
The channels.php
is a configuration file used to define the channels your application supports for broadcasting events. This file is located in the routes
directory and plays a crucial role in setting up WebSocket channels, both public and private, for real-time event broadcasting.
- Public Channels: Accessible to anyone.
- Private Channels: Require authentication to join.
We will now add a private channel for the chat app:
channels.php
<?php
use Illuminate\Support\Facades\Broadcast;
// Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
// return (int) $user->id === (int) $id;
// });
Broadcast::channel('video-call.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Step 9: Create a Controller
We will now create to handle our video chat API. Run this command to create a controller:
php artisan make:controller VideoCallController
Add these lines of code:
<?php
namespace App\Http\Controllers;
use App\Events\RequestVideoCall;
use App\Events\RequestVideoCallStatus;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class VideoCallController extends Controller
{
public function requestVideoCall(Request $request, User $user)
{
$user->peerId = $request->peerId;
$user->fromUser = Auth::user();
broadcast(new RequestVideoCall($user));
return response()->json($user);
}
public function requestVideoCallStatus(Request $request, User $user) {
$user->peerId = $request->peerId;
$user->fromUser = Auth::user();
broadcast(new RequestVideoCallStatus($user));
return response()->json($user);
}
}
Step 10: Define Routes
Update web.php to include routes for the VideoCallController:
web.php
<?php
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\VideoCallController;
use App\Models\User;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Welcome', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'laravelVersion' => Application::VERSION,
'phpVersion' => PHP_VERSION,
]);
});
Route::get('/dashboard', function () {
return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
Route::get('/contacts', function () {
$users = User::where('id', '!=', Auth::user()->id)->get();
return Inertia::render('Contacts', ['users' => $users]);
})->middleware(['auth', 'verified'])->name('contacts');
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::post('/video-call/request/{user}', [VideoCallController::class, 'requestVideoCall'])->name('video-call.request');
Route::post('/video-call/request/status/{user}', [VideoCallController::class, 'requestVideoCallStatus'])->name('video-call.request-status');
});
require __DIR__.'/auth.php';
Step 11: Install PeerJs
We will now install peerjs.
npm install peerjs
Step 12: Create Contacts Page And Update AuthenticatedLayout
We will now create a react component for our inbox page. Create a file inside resources\js\Pages\, the filename is Contacts.jsx. And add these lines of codes:
resources\js\Pages\Contacts.jsx
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
import axios from 'axios';
import Peer from 'peerjs';
import { useEffect, useRef, useState } from 'react';
export default function Contacts({ auth, users }) {
const [selectedUser, setSelectedUser] = useState(null)
const [peer, setPeer] = useState(new Peer())
const [peerCall, setPeerCall] = useState(null)
const remoteVideoRef = useRef(null);
const localVideoRef = useRef(null)
const [isCalling, setIsCalling] = useState(false)
const localStreamRef = useRef(null)
const selectedUserRef = useRef(null)
useEffect(()=>{
selectedUserRef.current = selectedUser
},[selectedUser])
const callUser = () => {
let payload = {
peerId: peer.id
}
axios.post(`/video-call/request/${selectedUserRef.current.id}`, payload);
setIsCalling(true)
displayLocalVideo();
}
const endCall = () => {
peerCall.close();
localStreamRef.current.getTracks().forEach(track => track.stop());
localVideoRef.current = null
remoteVideoRef.current = null
setIsCalling(false)
}
const displayLocalVideo = () => {
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then((stream) => {
localVideoRef.current.srcObject = stream
localStreamRef.current = stream
})
.catch((err) => {
console.error('Error accessing media devices:', err);
});
}
const recipientAcceptCall = (e) => {
// send signal that recipient accept the call
axios.post(`/video-call/request/status/${e.user.fromUser.id}`, { peerId: peer.id, status: 'accept'});
// stand by for callers connection
peer.on('call', (call) => {
// will be used when ending a call
setPeerCall(call)
// accept call if the caller is the one that you accepted
if(e.user.peerId == call.peer) {
// Prompt user to allow media devices
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then((stream) => {
// Answer the call with your stream
call.answer(stream);
// Listen for the caller's stream
call.on('stream', (remoteStream) => {
remoteVideoRef.current.srcObject = remoteStream
});
// caller end the call
call.on('close', () => {
endCall()
});
})
.catch((err) => {
console.error('Error accessing media devices:', err);
});
}
});
}
const createConnection = (e) => {
let receiverId = e.user.peerId
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then((stream) => {
// Initiate the call with the receiver's ID
const call = peer.call(receiverId, stream);
// will be used when ending a call
setPeerCall(call)
// Listen for the receiver's stream
call.on('stream', (remoteStream) => {
remoteVideoRef.current.srcObject = remoteStream
});
// receiver end the call
call.on('close', () => {
endCall()
});
})
.catch((err) => {
console.error('Error accessing media devices:', err);
});
}
const connectWebSocket = () => {
// request video call
window.Echo.private(`video-call.${auth.user.id}`).listen('RequestVideoCall', (e) => {
setSelectedUser(e.user.fromUser)
setIsCalling(true)
recipientAcceptCall(e)
displayLocalVideo();
});
// video call request accepted
window.Echo.private(`video-call.${auth.user.id}`).listen('RequestVideoCallStatus', (e) => {
createConnection(e)
});
}
useEffect(()=>{
connectWebSocket();
return () => {
window.Echo.leave(`video-call.${auth.user.id}`);
}
},[])
return (
<AuthenticatedLayout>
<Head title="Contacts" />
<div className="h-screen flex bg-gray-100" style={{height:'90vh'}}>
{/* Sidebar */}
<div className="w-1/4 bg-white border-r border-gray-200">
<div className="p-4 bg-gray-100 font-bold text-lg border-b border-gray-200">
Contacts
</div>
<div className="p-4 space-y-4">
{/* Contact List */}
{users.map((user, key) => (
<div
key={key}
onClick={()=>setSelectedUser(user)}
className={`flex items-center ${user.id == selectedUser?.id ? 'bg-blue-500 text-white' : ''} p-2 hover:bg-blue-500 hover:text-white rounded cursor-pointer`}
>
<div className="w-12 h-12 bg-blue-200 rounded-full"></div>
<div className="ml-4">
<div className="font-semibold">{user.name}</div>
</div>
</div>
))}
</div>
</div>
{/* Contacts Area */}
<div className="flex flex-col w-3/4">
{!selectedUser &&
<div className=' h-full flex justify-center items-center text-gray-800 font-bold'>
Select Conversation
</div>
}
{selectedUser &&
<>
{/* Contact Header */}
<div className="p-4 border-b border-gray-200 flex items-center">
<div className="w-12 h-12 bg-blue-200 rounded-full"></div>
<div className="ml-4">
<div className="font-bold">{selectedUser?.name}
{!isCalling &&
<button onClick={()=>callUser()} className="ml-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Call</button>
}
{isCalling &&
<button onClick={()=>endCall()} className="ml-4 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">End Call</button>
}
</div>
</div>
</div>
{/* Contact Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50 relative">
{isCalling &&
<>
<video id="remoteVideo" ref={remoteVideoRef} autoPlay playsInline muted className="border-2 border-gray-800 w-full"></video>
<video id="localVideo" ref={localVideoRef} autoPlay playsInline muted className="m-0 border-2 border-gray-800 absolute top-6 right-6 w-4/12" style={{margin:'0'}}></video>
</>
}
{!isCalling &&
<div
className="h-full flex justify-center items-center text-gray-800 font-bold"
>
No Ongoing Call.
</div>
}
</div>
</>
}
</div>
</div>
</AuthenticatedLayout>
);
}
resources\js\Layouts\AuthenticatedLayout.jsx
import ApplicationLogo from '@/Components/ApplicationLogo';
import Dropdown from '@/Components/Dropdown';
import NavLink from '@/Components/NavLink';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink';
import { Link, usePage } from '@inertiajs/react';
import { useState } from 'react';
export default function AuthenticatedLayout({ header, children }) {
const user = usePage().props.auth.user;
const [showingNavigationDropdown, setShowingNavigationDropdown] =
useState(false);
return (
<div className="min-h-screen bg-gray-100">
<nav className="border-b border-gray-100 bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
<div className="flex shrink-0 items-center">
<Link href="/">
<ApplicationLogo className="block h-9 w-auto fill-current text-gray-800" />
</Link>
</div>
<div className="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink
href={route('dashboard')}
active={route().current('dashboard')}
>
Dashboard
</NavLink>
<NavLink
href={route('contacts')}
active={route().current('contacts')}
>
Contacts
</NavLink>
</div>
</div>
<div className="hidden sm:ms-6 sm:flex sm:items-center">
<div className="relative ms-3">
<Dropdown>
<Dropdown.Trigger>
<span className="inline-flex rounded-md">
<button
type="button"
className="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out hover:text-gray-700 focus:outline-none"
>
{user.name}
<svg
className="-me-0.5 ms-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</span>
</Dropdown.Trigger>
<Dropdown.Content>
<Dropdown.Link
href={route('profile.edit')}
>
Profile
</Dropdown.Link>
<Dropdown.Link
href={route('logout')}
method="post"
as="button"
>
Log Out
</Dropdown.Link>
</Dropdown.Content>
</Dropdown>
</div>
</div>
<div className="-me-2 flex items-center sm:hidden">
<button
onClick={() =>
setShowingNavigationDropdown(
(previousState) => !previousState,
)
}
className="inline-flex items-center justify-center rounded-md p-2 text-gray-400 transition duration-150 ease-in-out hover:bg-gray-100 hover:text-gray-500 focus:bg-gray-100 focus:text-gray-500 focus:outline-none"
>
<svg
className="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
className={
!showingNavigationDropdown
? 'inline-flex'
: 'hidden'
}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
className={
showingNavigationDropdown
? 'inline-flex'
: 'hidden'
}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<div
className={
(showingNavigationDropdown ? 'block' : 'hidden') +
' sm:hidden'
}
>
<div className="space-y-1 pb-3 pt-2">
<ResponsiveNavLink
href={route('dashboard')}
active={route().current('dashboard')}
>
Dashboard
</ResponsiveNavLink>
<ResponsiveNavLink
href={route('contacts')}
active={route().current('contacts')}
>
Contacts
</ResponsiveNavLink>
</div>
<div className="border-t border-gray-200 pb-1 pt-4">
<div className="px-4">
<div className="text-base font-medium text-gray-800">
{user.name}
</div>
<div className="text-sm font-medium text-gray-500">
{user.email}
</div>
</div>
<div className="mt-3 space-y-1">
<ResponsiveNavLink href={route('profile.edit')}>
Profile
</ResponsiveNavLink>
<ResponsiveNavLink
method="post"
href={route('logout')}
as="button"
>
Log Out
</ResponsiveNavLink>
</div>
</div>
</div>
</nav>
{header && (
<header className="bg-white shadow">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{header}
</div>
</header>
)}
<main>{children}</main>
</div>
);
}
If some tailwind classes are not working, please rerun npm run dev and clear your browser cache!
Step 13: Run The App
These are the commands you need to rerun your app. Since we already run this command in our previous step, there is no need to run the commands below.
Starts the development server of the Laravel application:
php artisan serve
Compile and build frontend assets:
npm run dev
Start Laravel Reverb WebSocket server:
php artisan reverb:start
Now test your app:
http://127.0.0.1:8000
http://127.0.0.1:8000/contacts