DEV Community

Cover image for Build a Scalable Video Chat App with Agora in Laravel
Kofi Mupati
Kofi Mupati

Posted on • Edited on • Originally published at agora.io

1

Build a Scalable Video Chat App with Agora in Laravel

Introduction

Laravel is a powerful PHP framework that aims to make the web development process easier without sacrificing application functionality. This is especially true when you try to build a video chat app with Laravel.

Previously, I built a video chat app with WebRTC and Laravel and wrote about it here: Adding Video Chat To Your Laravel App. WebRTC is only one of the ways that people can implement video chat features. Companies like Agora also provide a fully packaged video chat SDK to provide a high-quality Real-Time Engagement video chat experience. As someone who has WebRTC development experience, I can tell you there are some limitations with WebRTC, such as:

  1. Quality of experience: Since WebRTC is transmitted over the Internet, which is a public domain, the quality of experience is hard to guarantee.
  2. Scalability: Scalability is fairly limited on group video calls due to the peer-to-peer nature of WebRTC.

After I was introduced to Agora, I was impressed that setting up the same video call feature is easier with Agora than with WebRTC. Let me walk you through building a video chat app with Agora in Laravel.

Why Agora Is the Preferred Solution

After building a video chat app with Agora, I want to highlight some of the advantages:

  1. There's one SDK for everything - voice, video, live streaming, screen sharing, and so on.
  2. I didn't have to set up a turn server with coturn on Amazon EC2 as I did in the other implementation to relay traffic between peers on different networks.
  3. You get 10,000 minutes every month free, and this gives you the flexibility to develop your solution prototype for free.
  4. You don't have the challenge of managing the underlying infrastructure supporting the video call functionality.
  5. Intuitive API documentation is available.

Prerequisites

Project Setup

  1. Open your terminal or console and navigate to your Laravel project directory.
  2. Install the necessary packages. ```bash composer require pusher/pusher-php-server "~4.0" npm install --save laravel-echo pusher-js ```
  3. Download the AgoraDynamicKey PHP code from the Agora repository: AgoraDynamicKey Keep the downloaded folder in a location outside the project folder. Some files from the folder will be copied into our project when we're configuring the back end.

Configuring the Backend

We will set up the various controllers and classes with methods needed to generate the Agora token to establish a call. Laravel's broadcasting system will also be activated.
We begin with the endpoints that will be accessed from the front end.

1. Add Application Routes.

Add the following code to routes/web.php.

<?php
Route::group(['middleware' => ['auth']], function () {
Route::get('/agora-chat', 'App\Http\Controllers\AgoraVideoController@index');
Route::post('/agora/token', 'App\Http\Controllers\AgoraVideoController@token');
Route::post('/agora/call-user', 'App\Http\Controllers\AgoraVideoController@callUser');
});
view raw web+snippet.php hosted with ❤ by GitHub

2. Activate Laravel's Broadcasting System.

Uncomment BroadcastServiceProvider in config/app.php in your project folder.

+ App\Providers\BroadcastServiceProvider::class
- //App\Providers\BroadcastServiceProvider::class 
Enter fullscreen mode Exit fullscreen mode

3. Create a presence channel in routes/channels.php

We get to know online users by looking at who has subscribed to this channel on the front end. We return their ID and name.

<?php
Broadcast::channel('agora-online-channel', function ($user) {
return ['id' => $user->id, 'name' => $user->name];
});

4. Create an event for making a call named MakeAgoraCall

This event will be used to make a call by broadcasting some data over the agora-online-channel. Run the following command in your terminal/console:

php artisan make:event MakeAgoraCall
Enter fullscreen mode Exit fullscreen mode

Add the following code to MakeAgoraCall.php

<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MakeAgoraCall implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $data;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($data)
{
$this->data = $data;
}
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PresenceChannel('agora-online-channel');
}
}

5. Add the downloaded AgoraDynamicKey generator files

  • Open your command line, and in your project's app directory create a directory named Class along with a subdirectory named AgoraDynamicKey. ```bash cd app mkdir -p Class/AgoraDynamicKey ```
  • Open the AgoraDynamicKey folder that was downloaded as part of the project setup. Next, copy the AccessToken.php and RtcTokenBuilder.php from the src directory into the AgoraDynamicKey directory that was created in the previous step.
  • Open AccessToken.php and RtcTokenBuilder.php, and add the project's namespace to make them accessible in a controller.

RtcTokenBuilder.php becomes:

<?php
namespace App\Classes\AgoraDynamicKey;
...
?>

AccessToken.php becomes:

<?php
namespace App\Classes\AgoraDynamicKey;
class Message
{
public $salt;
public $ts;
public $privileges;
public function __construct()
{
$this->salt = rand(0, 100000);
$date = new \DateTime("now", new \DateTimeZone('UTC'));
$this->ts = $date->getTimestamp() + 24 * 3600;
$this->privileges = array();
}
...
?>
view raw AccessToken.php hosted with ❤ by GitHub

6. Create the AgoraVideoController.php

Next, create the AgoraVideoController using the command line.

php artisan make:controller AgoraVideoController
Enter fullscreen mode Exit fullscreen mode

Add the following code to AgoraVideoController.php:

<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Classes\AgoraDynamicKey\RtcTokenBuilder;
use App\Events\MakeAgoraCall;
class AgoraVideoController extends Controller
{
public function index(Request $request)
{
// fetch all users apart from the authenticated user
$users = User::where('id', '<>', Auth::id())->get();
return view('agora-chat', ['users' => $users]);
}
public function token(Request $request)
{
$appID = env('AGORA_APP_ID');
$appCertificate = env('AGORA_APP_CERTIFICATE');
$channelName = $request->channelName;
$user = Auth::user()->name;
$role = RtcTokenBuilder::RoleAttendee;
$expireTimeInSeconds = 3600;
$currentTimestamp = now()->getTimestamp();
$privilegeExpiredTs = $currentTimestamp + $expireTimeInSeconds;
$token = RtcTokenBuilder::buildTokenWithUserAccount($appID, $appCertificate, $channelName, $user, $role, $privilegeExpiredTs);
return $token;
}
public function callUser(Request $request)
{
$data['userToCall'] = $request->user_to_call;
$data['channelName'] = $request->channel_name;
$data['from'] = Auth::id();
broadcast(new MakeAgoraCall($data))->toOthers();
}
}

Breakdown of Methods in the AgoraVideoController

  • index: To view the video call page.
  • token: To generate the Agora dynamic token. The token generation code is taken from sample/RtcTokenBuilderSample.php, which can be found in the files downloaded during the project setup.
  • callUser: To call a particular user by broadcasting a MakeAgoraCall event across a Laravel presence channel that I've named agora-online-channel. All logged-in users subscribe and listen to events on this channel.

The data received from the MakeAgoraEvent by the users subscribed to the agora-online-channel contains the following:

  • userToCall: This is the ID of the user who is supposed to receive a call from a caller.
  • channelName: This is the call channel that the caller has already joined on the front end. This is a channel created with the Agora SDK on the front end. It is the room the caller has already joined, waiting for the callee to also join to establish a call connection.
  • from: The ID of the caller.

From the MakeAgoraEvent, a user can determine whether they are being called if the userToCall value matches their ID. We show an incoming call notification with a button to accept the call.
They know who the caller is by the value of from.

Configuring the Front End

We are going to create the user interface for making and receiving the video call with the ability to toggle the on and off states of the camera and the microphone.

1. Add a link to the Agora SDK

Add the following to the head tag of resources/views/layouts/app.blade.php.


It then becomes:

<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}" defer></script>
<script src="https://cdn.agora.io/sdk/release/AgoraRTCSDK-3.3.1.js"></script>
<!-- Fonts -->
<link rel="dns-prefetch" href="//fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css?family=Nunito" rel="stylesheet">
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
</head>
<body>
</body>
</html>

2. Instantiate Laravel Echo and Pusher Instantiate Laravel Echo and Pusher in resources/js/bootstrap.js by uncommenting the following block of code:

import Echo from 'laravel-echo';
window.Pusher = require('pusher-js');
window.Echo = new Echo({
broadcaster: 'pusher',
key: process.env.MIX_PUSHER_APP_KEY,
cluster: process.env.MIX_PUSHER_APP_CLUSTER,
forceTLS: true
});

3. Create an Agora chat component

On your terminal or command line, create a component called AgoraChat.vue with the following command:

touch resources/js/components/AgoraChat.vue
Enter fullscreen mode Exit fullscreen mode

Add the following code:

<template>
<main>
<div class="container">
<div class="row">
<div class="col-12 text-center">
<img src="img/agora-logo.png" alt="Agora Logo" class="img-fuild" />
</div>
</div>
</div>
<div class="container my-5">
<div class="row">
<div class="col">
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-primary mr-2"
v-for="user in allusers"
:key="user.id"
@click="placeCall(user.id, user.name)"
>
Call {{ user.name }}
<span class="badge badge-light">{{
getUserOnlineStatus(user.id)
}}</span>
</button>
</div>
</div>
</div>
<!-- Incoming Call -->
<div class="row my-5" v-if="incomingCall">
<div class="col-12">
<p>
Incoming Call From <strong>{{ incomingCaller }}</strong>
</p>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-danger"
data-dismiss="modal"
@click="declineCall"
>
Decline
</button>
<button
type="button"
class="btn btn-success ml-5"
@click="acceptCall"
>
Accept
</button>
</div>
</div>
</div>
<!-- End of Incoming Call -->
</div>
<section id="video-container" v-if="callPlaced">
<div id="local-video"></div>
<div id="remote-video"></div>
<div class="action-btns">
<button type="button" class="btn btn-info" @click="handleAudioToggle">
{{ mutedAudio ? "Unmute" : "Mute" }}
</button>
<button
type="button"
class="btn btn-primary mx-4"
@click="handleVideoToggle"
>
{{ mutedVideo ? "ShowVideo" : "HideVideo" }}
</button>
<button type="button" class="btn btn-danger" @click="endCall">
EndCall
</button>
</div>
</section>
</main>
</template>
<script>
export default {
name: "AgoraChat",
props: ["authuser", "authuserid", "allusers", "agora_id"],
data() {
return {
callPlaced: false,
client: null,
localStream: null,
mutedAudio: false,
mutedVideo: false,
userOnlineChannel: null,
onlineUsers: [],
incomingCall: false,
incomingCaller: "",
agoraChannel: null,
};
},
mounted() {
this.initUserOnlineChannel();
this.initUserOnlineListeners();
},
methods: {
/**
* Presence Broadcast Channel Listeners and Methods
* Provided by Laravel.
* Websockets with Pusher
*/
initUserOnlineChannel() {
this.userOnlineChannel = window.Echo.join("agora-online-channel");
},
initUserOnlineListeners() {
this.userOnlineChannel.here((users) => {
this.onlineUsers = users;
});
this.userOnlineChannel.joining((user) => {
// check user availability
const joiningUserIndex = this.onlineUsers.findIndex(
(data) => data.id === user.id
);
if (joiningUserIndex < 0) {
this.onlineUsers.push(user);
}
});
this.userOnlineChannel.leaving((user) => {
const leavingUserIndex = this.onlineUsers.findIndex(
(data) => data.id === user.id
);
this.onlineUsers.splice(leavingUserIndex, 1);
});
// listen to incomming call
this.userOnlineChannel.listen("MakeAgoraCall", ({ data }) => {
if (parseInt(data.userToCall) === parseInt(this.authuserid)) {
const callerIndex = this.onlineUsers.findIndex(
(user) => user.id === data.from
);
this.incomingCaller = this.onlineUsers[callerIndex]["name"];
this.incomingCall = true;
// the channel that was sent over to the user being called is what
// the receiver will use to join the call when accepting the call.
this.agoraChannel = data.channelName;
}
});
},
getUserOnlineStatus(id) {
const onlineUserIndex = this.onlineUsers.findIndex(
(data) => data.id === id
);
if (onlineUserIndex < 0) {
return "Offline";
}
return "Online";
},
async placeCall(id, calleeName) {
try {
// channelName = the caller's and the callee's id. you can use anything. tho.
const channelName = `${this.authuser}_${calleeName}`;
const tokenRes = await this.generateToken(channelName);
// Broadcasts a call event to the callee and also gets back the token
await axios.post("/agora/call-user", {
user_to_call: id,
username: this.authuser,
channel_name: channelName,
});
this.initializeAgora();
this.joinRoom(tokenRes.data, channelName);
} catch (error) {
console.log(error);
}
},
async acceptCall() {
this.initializeAgora();
const tokenRes = await this.generateToken(this.agoraChannel);
this.joinRoom(tokenRes.data, this.agoraChannel);
this.incomingCall = false;
this.callPlaced = true;
},
declineCall() {
// You can send a request to the caller to
// alert them of rejected call
this.incomingCall = false;
},
generateToken(channelName) {
return axios.post("/agora/token", {
channelName,
});
},
/**
* Agora Events and Listeners
*/
initializeAgora() {
this.client = AgoraRTC.createClient({ mode: "rtc", codec: "h264" });
this.client.init(
this.agora_id,
() => {
console.log("AgoraRTC client initialized");
},
(err) => {
console.log("AgoraRTC client init failed", err);
}
);
},
async joinRoom(token, channel) {
this.client.join(
token,
channel,
this.authuser,
(uid) => {
console.log("User " + uid + " join channel successfully");
this.callPlaced = true;
this.createLocalStream();
this.initializedAgoraListeners();
},
(err) => {
console.log("Join channel failed", err);
}
);
},
initializedAgoraListeners() {
// Register event listeners
this.client.on("stream-published", function (evt) {
console.log("Publish local stream successfully");
console.log(evt);
});
//subscribe remote stream
this.client.on("stream-added", ({ stream }) => {
console.log("New stream added: " + stream.getId());
this.client.subscribe(stream, function (err) {
console.log("Subscribe stream failed", err);
});
});
this.client.on("stream-subscribed", (evt) => {
// Attach remote stream to the remote-video div
evt.stream.play("remote-video");
this.client.publish(evt.stream);
});
this.client.on("stream-removed", ({ stream }) => {
console.log(String(stream.getId()));
stream.close();
});
this.client.on("peer-online", (evt) => {
console.log("peer-online", evt.uid);
});
this.client.on("peer-leave", (evt) => {
var uid = evt.uid;
var reason = evt.reason;
console.log("remote user left ", uid, "reason: ", reason);
});
this.client.on("stream-unpublished", (evt) => {
console.log(evt);
});
},
createLocalStream() {
this.localStream = AgoraRTC.createStream({
audio: true,
video: true,
});
// Initialize the local stream
this.localStream.init(
() => {
// Play the local stream
this.localStream.play("local-video");
// Publish the local stream
this.client.publish(this.localStream, (err) => {
console.log("publish local stream", err);
});
},
(err) => {
console.log(err);
}
);
},
endCall() {
this.localStream.close();
this.client.leave(
() => {
console.log("Leave channel successfully");
this.callPlaced = false;
},
(err) => {
console.log("Leave channel failed");
}
);
},
handleAudioToggle() {
if (this.mutedAudio) {
this.localStream.unmuteAudio();
this.mutedAudio = false;
} else {
this.localStream.muteAudio();
this.mutedAudio = true;
}
},
handleVideoToggle() {
if (this.mutedVideo) {
this.localStream.unmuteVideo();
this.mutedVideo = false;
} else {
this.localStream.muteVideo();
this.mutedVideo = true;
}
},
},
};
</script>
<style scoped>
main {
margin-top: 50px;
}
#video-container {
width: 700px;
height: 500px;
max-width: 90vw;
max-height: 50vh;
margin: 0 auto;
border: 1px solid #099dfd;
position: relative;
box-shadow: 1px 1px 11px #9e9e9e;
background-color: #fff;
}
#local-video {
width: 30%;
height: 30%;
position: absolute;
left: 10px;
bottom: 10px;
border: 1px solid #fff;
border-radius: 6px;
z-index: 2;
cursor: pointer;
}
#remote-video {
width: 100%;
height: 100%;
position: absolute;
left: 0;
right: 0;
bottom: 0;
top: 0;
z-index: 1;
margin: 0;
padding: 0;
cursor: pointer;
}
.action-btns {
position: absolute;
bottom: 20px;
left: 50%;
margin-left: -50px;
z-index: 3;
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
#login-form {
margin-top: 100px;
}
</style>
view raw AgoraChat.vue hosted with ❤ by GitHub

Breakdown of the AgoraChat Component

On the AgoraChat page, we display buttons that bear the name of each registered user and whether they are online or offline at the moment.

To place a call, we click the button of a user with online status. An online user indicates one who is readily available to receive a call. For our demo, we see a list of users. The user named Bar is indicated as being online. The caller named Foo can call Bar by clicking the button.

Users to Call
Bar gets an incoming call notification with Accept and Decline buttons and the name of the caller.

Call Notification

From the call notification image above, we see that the caller's name is Foo. Bar can then accept the call for a connection to be established.

The following diagram explains the call logic in terms of the code:

The Call Logic

4. Register the AgoraChat.vue component

Add the following code to resources/js/app.js:

Vue.component("agora-chat", require("./components/AgoraChat.vue").default);
view raw app+snippet.js hosted with ❤ by GitHub

5. Create an Agora chat view

On your terminal or command line, create a view called agora-chat.blade.php with the following command:

touch resources/views/agora-chat.blade.php
Enter fullscreen mode Exit fullscreen mode

Add the following code to the agora-chat.blade.php:

@extends('layouts.app')
@section('content')
<agora-chat :allusers="{{ $users }}" authuserid="{{ auth()->id() }}" authuser="{{ auth()->user()->name }}"
agora_id="{{ env('AGORA_APP_ID') }}" />
@endsection

6. Update env variables with Pusher and Agora keys

The .env file is located at the root of your project folder. Add the credentials you got from Agora and Pusher.

BROADCAST_DRIVER=pusher
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=
AGORA_APP_ID=
AGORA_APP_CERTIFICATE=
view raw .env hosted with ❤ by GitHub

Testing

  1. Start the Laravel development server from your terminal.br/> ```bash php artisan serve ```
  2. Open another terminal and run the front end. ```bash npm run dev ```
  3. Open two different browsers or two instances of the same browser, with one instance in incognito mode, and go to http://127.0.0.1:8000/agora-chat.
  4. You are presented with a login page if you are not already logged in.
  5. After successful login, you are automatically redirected to the video chat page, where you see the buttons with names of the users with an indication of their online status.
  6. In each of the browsers you opened, the other users registered on the application are displayed as described in 5.
  7. In one browser, you can call the user who is logged in on the other browser by clicking the button that bears their name.
  8. The other user is prompted to click the Accept button to fully establish the call.

Video Demonstration of the Video Call

To confirm that your demo is functioning properly, see my demo video as an example of how the finished project should look and function:

Conclusion

You have now implemented the video chat feature in Laravel! It's not that hard, right?

To include video calling functionality in your web app, you don't have to build it from scratch.

Agora provides a lot of great features out of the box. It also helps businesses save development hours when implementing video chat into existing projects. The only thing a developer has to do is build a compelling front end - Agora handles the video chat back end.

Link to project repository: https://github.com/Mupati/laravel-video-chat
 
Demo link: https://laravel-video-call.herokuapp.com/agora-chat

Make sure the demo link or production version is served on HTTPS.

Test accounts:

foo@example.com: DY6m7feJtbnx3ud

bar@example.com: Me3tm5reQpWcn3Q

Other Resources

I also invite you to join the Agora.io Developer Slack community.

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (2)

Collapse
 
ships09 profile image
ships09

Hi, I followed the steps to create Video Chat App with Agora in Laravel and created project. Created two users. Added the AGORA_APP_ID and AGORA_APP_CERTIFICATE.
When I login with two different users on two chrome browsers and click the call button the video box opens but on other window it doesn't show the Accept or Decline buttons. I don't see any error in console.
Also when the user always shows offline, unlike your demo link where the signed in users appear as online. Any idea if I am doing anything wrong or missing.

Collapse
 
ships09 profile image
ships09

Also when I do, npm run start it gives this error .npm ERR! missing script: start

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs