Build a Real-time Chat Room App with Laravel, React, and Twilio Conversations

June 23, 2021
Written by
Lloyd MIller
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Real-time Chat Room App with Laravel, React, and Twilio Conversations

Previously, we built a Discord-inspired chat room app with Laravel Breeze, React, and Twilio’s new Conversation API. That article was a proof-of-concept to show you the possibilities with Laravel Breeze and Inertia.

Now, we’ll continue from where we left off and make our app “real-time”. We’ll achieve this with the Webhooks feature in Twilio’s Conversation API, and WebSockets. We’ll also be handling errors the Inertia way.

In this article, we’ll be using Pusher, which is the simplest way of implementing WebSockets into our app. Redis is another great—and non-commercial—solution, but that’s for another time. Now for the fun!

Prerequisites

Before getting started

If you haven’t already read it, please read the previous article, as this article builds on the app that we created there. However, if you want to start right away, set up the project by running the commands below.

git clone git@gitlab.com:Lloydinator/twilcord-pt1.git
cd twilcord-pt1
composer install
npm install
cp .env.example .env
php artisan key:generate

With the application bootstrapped, the dependencies installed, and the APP_KEY generated, there's one thing left to do: set the values of TWILIO_PHONE_NUMBER, TWILIO_ACCOUNT_SID, and TWILIO_AUTH_TOKEN in .env.

To retrieve these values, navigate to the Twilio Console and locate the ACCOUNT SID and AUTH TOKEN inside of the Project Info Dashboard, as seen below.

Retrieve Twilio API credentials

Note: The value for your Twilio phone number must be in E.164 format. So, if your number is +1 213 456 7899, then you have to set TWILIO_PHONE_NUMBER to +12134567899.

To retrieve the phone number, run twilio phone-numbers:list in your terminal and choose the applicable number from the list, copying the value from the Phone Number column.

Note: If you don't already have a phone number, you can search and buy a Twilio Phone Number from the console.

Let’s redo our pages and components

In our previous article, our frontend was basically just one page where the components switched based on whether we were trying to sign in or chat. Now, we need to create proper routes where our auth and chat will be separate. So first, let’s create an Auth page.

To do that, create a new Javascript file named Auth.js in resources/js/Pages. Then, paste the code below into it.

import React from 'react'
import SignUp from '../Components/Signup'

const Auth = props => {
    return (
        <SignUp flash={props} />
    )
}

export default Auth

With the file created, replace the contents of resources/js/Components/signup.js with the code below. It gets rid of functions such as changeChat() and other code that is no longer needed. We’ll explain props and the new Notification component later on.

import React, {useState, useEffect} from 'react'
import {Inertia} from '@inertiajs/inertia'
import Notification from './Notification'

const SignUp = ({flash}) => {
    const [username, setUsername] = useState('')
    const [userType, setUserType] = useState('username')
    const [convo, setConvo] = useState({sid: '', name: ''})
    const [chatExists, setChatExists] = useState(false)
    const [submitting, setSubmitting] = useState(false)

    function handleChange(e){
        setUsername(e.target.value)
    }

    function handleSubmit(e){
        e.preventDefault()
        
        // If user doesn't check the box to join existing room
        if (chatExists === false){
            Inertia.post('/convo/create', {}, {
                onSuccess: ({props}) => {

                    // If user joins by username
                    if (userType === 'username'){
                        Inertia.post(
                            `/convo/${props.flash.message}/chat-participant/new`,
                            {username: username},
                            {
                                onStart: () => {
                                    setSubmitting(true)
                                },
                                onFinish: () => {
                                    setSubmitting(false)
                                }
                            }
                        )
                    }
                    // If user joins by number
                    else {
                        Inertia.post(
                            `/convo/${props.flash.message}/sms-participant/new`,
                            {number: username},
                            {
                                onStart: () => {
                                    setSubmitting(true)
                                },
                                onFinish: () => {
                                    setSubmitting(false)
                                }
                            }
                        )
                    }
                }
            })
        }
        // If user checks the box to join existing room
        else {
            // If user joins by username
            if (userType === 'username'){
                Inertia.post(
                    `/convo/${convo.sid}/chat-participant/new`,
                    {username: username},
                    {
                        onStart: () => {
                            setSubmitting(true)
                        },
                        onFinish: () => {
                            setSubmitting(false)
                        }
                    }
                )
            }
            // If user joins by number
            else {
                Inertia.post(
                    `/convo/${convo.sid}/sms-participant/new`,
                    {number: username},
                    {
                        onStart: () => {
                            setSubmitting(true)
                        },
                        onFinish: () => {
                            setSubmitting(false)
                        }
                    }
                )
            }
        }
    }

    // Fetch data from sid.json
    async function jsonFile(){
        const response = await fetch('./sid.json', {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        })
        const data = await response.json()
        setConvo({sid: data.sid, name: data.chat_name})
    }

    useEffect(() => {
        jsonFile()
    }, [])
  
    return (
        <div className="flex justify-center">
            <div className="align-middle mt-20">
                <Notification notification={flash.flash.notification} />
                <form onSubmit={handleSubmit}>
                    {convo.name ? (
                        <div className="mt-2">
                            <div>
                                <label className="inline-flex items-center">
                                    <input 
                                        type="checkbox" 
                                        className="form-checkbox" 
                                        onChange={() => setChatExists(!chatExists)} 
                                    />
                                    <span className="ml-2">Join Chat "{convo.name}"</span>
                                </label>
                            </div>
                        </div>
                    ) : (
                        null
                    )}
                    <input 
                        id="username"
                        value={username}
                        onChange={handleChange}
                        className="my-6 p-3 block w-full rounded-md bg-gray-100 border-transparent focus:border-gray-500 focus:ring-0" 
                    />
                    <div className="flex">
                        <button 
                            className="bg-purple-500 text-white active:bg-purple-600 font-bold uppercase text-sm px-6 py-3 rounded-full shadow-inner outline-none focus:outline-none mr-1 mb-1" 
                            onClick={() => setUserType('username')}
                            disabled={submitting}
                        >
                            {submitting ? "Submitting..." : "Enter with Username"}
                        </button>
                        <button 
                            className="bg-purple-500 text-white active:bg-purple-600 font-bold uppercase text-sm px-6 py-3 rounded-full shadow-inner outline-none focus:outline-none mr-1 mb-1" 
                            onClick={() => setUserType('number')}
                            disabled={submitting}
                        >
                            {submitting ? "Submitting..." : "Enter with Number"}
                        </button>
                    </div>
                </form>
            </div>
        </div>
    )
    
}

export default SignUp

We’ll need to make a few changes to our Chatform component as well, to get rid of the functions we’ll no longer need—especially our useEffect hook that was in charge of updating our chat every 3 seconds.

Again, don’t worry about the props or the new Notification component, we’ll explain those later on. Replace the current contents of resources/js/Components/chatform.js with the code below.

import React, {useState, useEffect} from 'react'
import {Inertia} from '@inertiajs/inertia'
import Message from './Message'
import Notification from './Notification'

const ChatForm = ({chat}) => {
    const [thisText, setThisText] = useState('')
    const [submitting, setSubmitting] = useState(false)
    const [chatName, setChatName] = useState('')

    function handleChange(e){
        setThisText(e.target.value)
    }

    function handleSubmit(e){
        e.preventDefault()
        
        Inertia.post(`/convo/${chat.sid}/create-message`, {
            message: thisText
        }, 
        {
            onStart: () => {
                setSubmitting(true)
            },
            onFinish: () => {
                clearField()
                setSubmitting(false)
            }
        })
    }

    function clearField(){
        setThisText('')
    }

    // Fetch data from sid.json
    async function jsonFile(){
        const response = await fetch('./sid.json', {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        })
        const data = await response.json()
        setChatName(data.chat_name)
    }

    useEffect(() => {
        jsonFile()
    }, [])

    return (
        <div className="h-screen mx-auto lg:w-1/2 md:w-4/6 w-full mt-2">
            <h1 className="font-mono font-semibold text-black text-4xl text-center my-6">TWILCORD</h1>
            <Notification notification={chat.flash.notification} />
            <div className="flex justify-between">
                <p className="font-sans font-semibold text-lg text-black">{chatName}</p>
                <p className="font-sans font-semibold text-lg text-black">{chat.user}</p>
            </div>
            <div className="h-3/4 overflow-y-scroll px-6 py-4 mb-2 bg-gray-800 rounded-md">
                {chat.convo.map((message, i) => (
                    <Message
                        key={i} 
                        time={message[3]}
                        username={message[1] == chat.user ? "Me" : message[1]} 
                        text={message[2]} 
                    />
                ))}
            </div>
            <form onSubmit={handleSubmit}>
                <div className="flex flex-row">
                    <textarea 
                        className="flex-grow m-2 py-2 px-4 mr-1 rounded-full border border-gray-300 bg-gray-200 outline-none resize-none"
                        rows="1"
                        placeholder="Place your message here..."
                        value={thisText}
                        onChange={handleChange}
                    />
                    <button 
                        type="submit" 
                        className="bg-purple-500 text-white active:bg-purple-600 font-bold uppercase text-sm px-6 py-3 rounded-full shadow-inner outline-none focus:outline-none mr-1 mb-1"
                        disabled={submitting}
                    >
                        {submitting ? "Sending" : "Send"}
                    </button>
                </div>
            </form>
        </div>
    )
}

export default ChatForm

Finally, we’ll rewrite our home page to only include the Chatform component, since we’re not doing the switching we did in the last article. To do that, replace the contents of (resources/js/Pages/Home.js) with the code below.

import React from 'react'
import ChatForm from '../Components/Chatform'

const Home = props => {
    return (
        <ChatForm chat={props} />
    )
}

export default Home

Let’s rewrite our routes

In the previous article, we set up Inertia to not return any data in our home page. This time, we’ll return our conversation through this route. But first, let’s create the route for our new Auth page, replacing the "Home" route in routes/web.php with the code below.

Route::get('/', function(Request $request, Twilio $convo){
    if (!$request->session()->has('user')){
        return redirect()->route('signin');
    }
    
    return Inertia::render('Home', [
        'convo' => $convo->listMessages($request->session()->get('sid')),
        'sid' => $request->session()->get('sid'),
        'user' => $request->session()->get('user')
    ]);
})->name('home');

Route::get('auth', function(){
    return Inertia::render('Auth', []);
})->name('signin');

Why did we give our route the name ‘signin’? Take a look at our new "Home" route above.

Here’s what this code does. When someone visits the home page, it will check to see if the user is signed in. If the user is not signed in, they will be redirected to the ‘signin’ route.

Note: Since we’re still not using databases or the User model, we’ll not be using the Auth facade. This is a very simplistic authentication, so please don’t take your security ideas from this tutorial. The user will be set later as conversation creation and user creation are similar to what we had the last time.

Next, again in routes/web.php, replace the "convo" route group with the following code.

Route::group(['prefix' => 'convo'], function(){
    // Create conversation
    Route::post('/create', function(Twilio $convo){
        $sid = $convo->makeConversation()->sid;        

        // Write JSON file
        FileHelpersTrait::handleJson($sid);
    
        return redirect()->back()->with('message', $sid);
    });

    // Add participant by phone number
    Route::post('/{id}/sms-participant/new', function(
            Request $request, Twilio $convo, $id
        ){
        $number = $request->number;
        $participant = $convo->createSMSParticipant($id, $number);

        // Set session
        $request->session()->put(['user' => $number, 'sid' => $id]);
        
        return redirect()->route('home')->with('message', $participant->sid);
    });
    
    // Add participant by username
    Route::post('/{id}/chat-participant/new', function(
            Request $request, Twilio $convo, $id
        ){
        $name = $request->username;
        $participant = $convo->createChatParticipant($id, $name);

        // Set session
        $request->session()->put(['user' => $name, 'sid' => $id]);
        
        return redirect()->route('home')->with('message', $participant->sid);
    });
    
    // Create a new message
    Route::post('/{id}/create-message', function(
            Request $request, Twilio $convo, $id
        ){
        $message = $convo->createMessage(
            $id, $request->session()->get('user'), $request->message
        );
    
        return redirect()->back()->with('message', $message->sid); 
    });
});

// Webhook endpoint
Route::post('hook', function(Request $request){
    Log::debug($request);
});

Don't forget to add the following use statement as well.

use Illuminate\Support\Facades\Log;

You’ll notice there are quite a few differences compared to last time. First of all, instead of instantiating our Twilio service every time we want to use it, we are now utilizing Laravel’s Service Container so that we can use our service through dependency injection.

We also deleted the route that fetched all our messages since we no longer need it; the home route does that now. Also, we are using sessions to save the user data when they sign in, either by phone number or by username.

Finally, you’ll notice that we have a route for our webhook endpoint, hook. The Log::debug($request) is only temporary and it’s only there to make sure that it’s working properly.

It won’t work properly on localhost, so we need to expose our webhook endpoint to the world wide web by firing up Ngrok. Do that by running the command below in the root directory of your project.

ngrok http 8000

You should see output similar to the following in your terminal.

Start ngrok

When it's running, copy the http URL from the terminal output and set it as the value of APP_URL in .env

Now, in a new terminal, run php artisan serve. Before we compile our assets, comment the import Notification lines in resources/js/Components/signup.js and resources/js/Components/chatform.js along with the Notification components. 

When that's done, run npm run dev in the root directory of the project to compile the frontend assets.

Note: if you're using Linux or macOS, you can background the artisan process before running the command above by pressing Ctrl+z and then bg. If you're using Microsoft Windows, however, you'll need to run the command in a new terminal window.

When successfully built, you should see output in the terminal similar to the screenshot below.

Successful Laravel Mix build

 

Then, open the ngrok URL in your browser. You should now be seeing the sign-in page. Sign in with both a username and your phone number in E.164 format.

What are webhooks and how do we use them?

In apps that use HTTP, we need to use methods such as GET, POST, PUT, and DELETE, to fetch, input, update, and delete data. HTTP is pretty much always “ask and respond”, which is fine, but it’s not fast. What if we want something to happen without us having to ask?

This is where webhooks come into play. Webhooks are messages that automatically get sent in response to an event. The payload in each webhook message contains data about the event.

Twilio’s Conversations API comes with a Webhooks feature that enables us to make our app real-time. There are two types of webhooks for Twilio: Pre-Event and Post-Event. We’ll be creating Post-Event webhooks, since Pre-Event webhooks are only used when we want to intercept an event.

Create webhook with Postman

Twilio allows us to configure our webhooks via the Console or via the API. The API is a lot more fun, so let’s fire up Postman, as in the screenshot above.

We’ll create a webhook resource via a POST request to the Conversations API. The request will need to send three things:

  1. A target (Target)
  2. A configuration URL (Configuration.Url)
  3. A configuration filter (Configuration.Filters)

The target will always be set to webhook. The configuration URL is our webhook endpoint, so its value will be the Ngrok URL plus /hook. The configuration filters tell Twilio what actions it should fire off webhooks for.

For this tutorial, we’ll only focus on onMessageAdded. However, you should take a look at the filters you have access to, such as onParticipantAdded and onDeliveryUpdated.

Note: You should make sure your calls are authorized by going to the Authorization tab and selecting Basic Auth. Here you will put your Twilio Account SID as your Username and Account Token as your Password.

We’ll create the new webhook with this endpoint that Twilio provides: https://conversations.twilio.com/v1/Conversations/{conversation_sid}/Webhooks. You can get the conversation SID from public/sid.json.

Oh, just one more thing. Laravel may block our webhook to prevent Cross-Site Scripting Attacks (XSS), so we’ll need to make a small configuration change. Open app/Http/Middleware/VerifyCsrfToken.php and change just one line, which you can see below.

    protected $except = [
        'hook'
    ];

After adding that configuration option, go back to Postman and click "Send" to create the webhook.

We can also automate this process by going to app/Services/Twilio.php and adding a few lines. We’ll use the APP_URL in our .env file to create a method for making webhooks. In Twilio.php, add a property to access APP_URL from our .env file and a method to create the webhook:

<?php

class Twilio {
   … 
    protected $url;

    public function __construct(){
       … 
        $this->url = getenv('APP_URL');
    }
   … 

    public function makeWebhook($sid){
        $hook = $this->client->conversations->v1    
                    ->conversations($sid)
                    ->webhooks
                    ->create("webhook", [
                        "configurationMethod" => "POST",
                        "configurationFilters" => ["onMessageAdded"],
                        "configurationUrl" => $this->url . "/hook"
                    ]);
        return $hook;
   } 
}

Now we can utilize this method every time a new conversation is created by adding just one line to our 'Create Conversation' route:

    // Create conversation
    Route::post('/create', function(Twilio $convo){
        $sid = $convo->makeConversation()->sid;        

        // Write JSON file and make webhook
        FileHelpersTrait::handleJson($sid);
        $convo->makeWebhook($sid);
    
        return redirect()->back()->with('message', $sid);
    });

Test the webhook

Now that you have your webhook set up, it’s time to test it out. Send a message with a username from the chat window and you should now see a text message in your phone from your Twilio number. Reply to that message from your phone and then go to storage/logs/laravel.log. You should then see the payload that was sent via the webhook.

Test the webhook

Nice! Now that we know that our webhook works, we can move on to getting our frontend to work in real-time.

Can you hear the echo?

As you might have realized, there is no mechanism for our chat to update when an SMS is sent. There is also no mechanism for our chat to update if another user connects to the chat and sends a message from their phone/computer.

For the frontend, the library that will help us do this is Laravel Echo. This library listens for any events that are broadcast from the server-side. On the backend, however, we’ll need to implement a bunch of tools and techniques in Laravel that you may or may not already be familiar with: Events, Broadcasting, WebSockets, and Pusher.

Events are pretty self-explanatory. In Laravel, events are fired off every time something happens in our application. Broadcasting, on the other hand, helps us to send events from the backend to the frontend via a WebSocket connection.

WebSockets, in essence, allow a true two-way connection between the client-side and the server-side. Therefore, to implement WebSockets in our app, we’ll be using Pusher. Even though there are other non-commercial solutions, Pusher is very easy to use and set up.

Let’s dig in.

Broadcast your first event

The first thing that we’ll need to do is install Pusher into our project by running the command below

composer require pusher/pusher-php-server "^5.0"

Note: Composer may still install version v5.0.0 so check composer.json. If you see "pusher/pusher-php-server": "5.0" then place the caret in to make it look like "pusher/pusher-php-server": "^5.0" and run composer update pusher/pusher-php-server. Please make sure you do this or you’ll get an error saying Undefined property: stdClass::$channels.

Now, add your Pusher App ID, App Key, and App Secret to .env to the PUSHER_APP_ID, PUSHER_APP_KEY, and PUSHER_APP_SECRET settings, respectively.

You can find these values in the "App Keys" section of your channel, as in the image below.

Get App keys from Pusher

Note: If you created your app in a different cluster to the default, then you'll need to change the default value of PUSHER_APP_CLUSTER as well, to match your app's cluster.

Next, make Pusher your broadcast driver by setting BROADCAST_DRIVER to pusher in .env, as in the example below

BROADCAST_DRIVER=pusher

Then, in config/app.php, uncomment the line App\Providers\BroadcastServiceProvider::class under "Application Service Providers". Let’s now create our event by running the following command.

php artisan make:event MessageCreated

Next, in routes/web.php, we’ll be instantiating and using the broadcast in the hook route. To do that, replace the existing definition with the code below:

// Webhook endpoint
Route::post('hook', function(Request $request){
    if ((int)$request['Index'] > $request->session()->get('count')){
        broadcast(new MessageCreated('sms'));
    }
    
    $request->session()->put('count', (int)$request['Index']);

    return new MessagingResponse();
});

Note: Don't forget to add the use statement for MessageCreated and MessagingResponse!

use App\Events\MessageCreated;
use Twilio\TwiML\MessagingResponse;

If you’re confused by this code, go back up to the screenshot of the log of our webhook. You’ll see that each key and value in our array is a string. We’re trying to say that the broadcast should only fire when it is indeed a new message, and we do that by comparing the current index count with the previous index count.

Since the index count is a string, we cast it to an integer. The 'sms' that we passed into the MessageCreated event will be passed to the class’s source property.

Note: The reason why we've returned a MessagingResponse object is to disable the default response that you'll receive when you send a message from your phone.

Now it’s time to set up the client-side. Install Laravel Echo and the Pusher Javascript library by running the command below in the root directory of the project.

npm install --save-dev laravel-echo pusher-js

Once that's done, open resources/js/bootstrap.js and uncomment the lines starting with `import Echo`, `window.Pusher`, and `window.Echo`. The bottom section of the file should then look something like the example below:

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
});

Also, make sure that resources/js/bootstrap.js is being imported into resources/js/app.js by adding import './bootstrap' to the top of the file.

Now we need to tell our front-end when the message was created. We’ll also need a mechanism to load the new message once our client gets the message. Luckily, Inertia comes with a feature called Partial Reloads that enables us to just reload a section of our page and only update the data that we want.

To do that, update resources/js/Pages/Home.js to look like the code below:

import React, { useEffect } from 'react'
import {Inertia} from '@inertiajs/inertia'
import ChatForm from '../Components/Chatform'

const Home = props => {
    useEffect(() => {
        window.Echo.channel('message').listen('MessageCreated', e => {
            Inertia.reload({only: ['convo']})
        })
    }, [])
    
    return (
        <ChatForm chat={props} />
    )
}

export default Home

After making these changes, every time the client receives the MessageCreated event, it will do a partial reload, only updating convo, which is an array that is returned by Inertia.

With those changes made, before we can test them, rebuild the front end assets, by running the following command in the root directory of the project.

npm run development

Restart your server and run npm run watch. Once your app compiles, send another reply to the text message you received earlier. Now go to the browser (you might also need to do a hard refresh of your page or open a new tab). Your message should come up in the chat without you having to do anything!

At the moment, if someone else sends a message, our chat won’t be updated. We need to fix that. Let your home route look like this:

// Home page with chat box
Route::get('/', function(Request $request, Twilio $convo){
    if (!$request->session()->has('user')){
        return redirect()->route('signin');
    }

    broadcast(new MessageCreated('chat'))->toOthers();
    
    return Inertia::render('Home', [
        'convo' => $convo->listMessages($request->session()->get('sid')),
        'sid' => $request->session()->get('sid'),
        'user' => $request->session()->get('user')
    ]);
})->name('home');

The toOthers() method broadcasts the event to everyone else except the current user. This prevents endless loops and other errors when our broadcast method is in our home route, where data is already loaded each time a message is created. You should now have a fully-functioning real-time chat room app.

Add in error handling

Currently, when an error occurs in our app, a modal appears. This is okay locally, but if we decided to push it to production, it would create a poor user experience. Let’s handle errors so that they load as text on our page.

Remember the Notification component in our frontend that we had to comment out, because we hadn't yet defined it? Let’s do that now. Create a new file in resources/js/Components, named Notification.js, and in there, add the code below.

import React, {useEffect, useState} from 'react'

const Notification = props => {
    const [message, setMessage] = useState('');
    
    useEffect(() => {
        if (props.notification){
            setMessage(props.notification.message)
        }
    }, [props.notification])
    
    return (
        <div 
            className={`${message ? '': 'hidden'} text-white px-6 py-4 border-0 rounded relative mb-4 bg-red-500`}
        >
            <span className="text-2xl font-bold inline-block mr-5 align-middle">
                !
            </span>
            <span className="inline-block max-w-lg align-middle mr-8">
                {message}
            </span>
            <button 
                type='button'
                className="absolute bg-transparent text-2xl font-semibold leading-none right-0 top-0 mt-4 mr-6 outline-none focus:outline-none"
                onClick={() => setMessage('')}    
            >
                <span>×</span>
            </button>
        </div>  
    )
}

export default Notification

With the code updated make sure that you uncomment all lines in resources/js/Components/chatform.js and resources/js/Components/signup.js related to "Notification". Also, remember to rebuild the front end code, by running the following command.

npm run dev

Our Notification component is pretty simplistic. If there is an error message, it will change from its default hidden state to render on the page. The error message is passed to the component by way of props.

Now, we’ll need to set up a mechanism by which the error message will be sent from the server to the client. Inertia has a preferred way for handling errors, but we will not do it their way in this tutorial.

In app/Exceptions/Handler.php, there is a register() method where we can define our exception handling functions. We’ll be using the renderable method to define our custom exception. Replace the body of the register method with the code below.

<?php

namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Twilio\Exceptions\TwilioException;

class Handler extends ExceptionHandler
{
    ...

    public function register()
    {
        $this->renderable(function(Throwable $e, $request) {
            if ($e instanceof TwilioException) {
                $status = 409;
            }
            else if ($e instanceof HttpException) {
                $status = $e->getStatusCode();
            }
            else {
                $status = 500;
            }

            switch ($status) {
                case 403:
                    $code = $status;
                    $message = "You're not allowed to see this."; 
                    break;
                case 409: 
                    $code = $status;
                    $message = substr($e->getMessage(), 11); 
                    break;
                case 419: 
                    $code = $status;
                    $message = "This page has expired. Please refresh."; 
                    break;
                default: 
                    $code = 500;
                    $message = "Something went wrong"; 
                    break;
                }

            return redirect()->back()->with([
                'inertia_error' => [
                    'code' => $code,
                    'message' => $message
                ]
            ]);
        });
    }
}

Note: Don't forget to update the use statements to match those in the example above.

The code checks if our error is an instance of TwilioException. If it is, we'll pass it as a 409 HTTP status code. If the error is not a TwilioException, we’ll presume that it's an HttpException and get its HTTP status code. For everything else, we’ll pass it as a 500 HTTP status code.

Now we need to make sure that our custom error messages are shared with Inertia. Open app/Providers/AppServiceProvider.php where we will be making another change to the boot() method. The final iteration of the boot() method should look like the code below:

    public function boot()
    {
        Inertia::share('flash', function(){
            return [
                'message' => Session::get('message'), 
                'notification' => Session::get('inertia_error')
            ];
        });
    }

To test out our new exception handling mechanism, let’s create a common error with the Twilio API. Enter a phone number in the wrong format and try to enter the chat with it. You should see an error like this:

Handle login errors when using phone numbers

That's how to build a real-time chat room app with Laravel, React, and Twilio Conversations Webhooks

This tutorial covered numerous concepts including webhooks, websockets, error handling with Inertia, and others. If you want to build this app into something for the real world, you should create a user model in which you can create a better authentication mechanism. You should also look into filtering for other actions with Twilio webhooks, such as onParticipantAdded and onDeliveryUpdated.

Lloyd Miller is a freelance full-stack web developer based in New York. He enjoys creating customer-facing products with Laravel and React, and documenting his journey through blog posts at https://blog.lloydmiller.dev