How to Build a Real-time SMS Chat App with Laravel, Inertiajs, React, and Pusher

Real-time chat applications like WhatsApp and Discord have witnessed a remarkable surge in popularity in recent years, revolutionising communication with instantaneous messaging. However, while building your own SMS chat app with this functionality might seem daunting, it’s entirely achievable.

In this tutorial, you will learn how to build a real-time SMS chat application using Laravel, React.js, and Pusher. You'll use the Laravel framework to build the back end of the chat application, a combination of Blade templates and React.js to develop the user interface and layout of the application, and the Pusher platform for the real-time chat functionality.

Prerequisites

To complete this tutorial, you will need the following:

Backend setup

Begin by setting up all the necessary backend configurations required for the chat application.

Scaffold a new Laravel project

To get started with building your chat application, you need to create a new Laravel project using Composer and change into the project directory by running the below commands in your terminal.

                                        
                                            composer create-project laravel/laravel sms-chat-app
cd sms-chat-app
                                        
                                    


Next, install Laravel Breeze using the command below.

                                        
                                            composer require laravel/breeze
                                        
                                    

Once the Laravel Breeze installation is complete, scaffold the authentication for the application. Do that by running the following commands one after the other.

                                        
                                            php artisan breeze:install
                                        
                                    

When prompted, select the options below.

The final step in the scaffolding process is to add the necessary packages and config files for WebSocket and event broadcast. Run the command below in a new terminal instance or tab.

                                        
                                            php artisan install:broadcasting
                                        
                                    

When prompted, select these options:

This command creates channels.php in the routes folder and broadcasting.php in the config folder. These files will hold the WebSocket configurations and install the necessary client libraries (Laravel Echo and pusher-js) needed to work with WebSockets.
With all of the scaffolding completed and the application running my application with Laravel Herk.

Once the application server is up, open http://sms-chat-app.test/ in your browser. You should see the default Laravel welcome page with options to log in or register for your chat app, as shown in the image below.

The next step is to open the project in your preferred IDE or text editor.

Configure Pusher

Pusher is a cloud-hosted service that makes adding real-time functionality to applications easy. It acts as a real-time communication layer between servers and clients. This allows your backend server to instantly broadcast new data via Pusher to your react inertiajs client.

Install the Pusher helper library by running the command below in a new terminal instance or tab.

                                        
                                            composer require pusher/pusher-php-server
                                        
                                    



Then, in your Pusher dashboard, create a new app by clicking Create App under Channels.

Next, fill out the form, as in the screenshot below, and click Create app.



After creating a new channels app, navigate to the App Keys section in the Pusher dashboard where you will find your Pusher credentials: App ID, Key, Secret, and Cluster. Note: these values as you will need them shortly.

Now, locate the .env file in the root directory of your project. This file is used to keep sensitive data and other configuration settings out of your code. Add the following lines to the bottom of the file:

                                        
                                            PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
BROADCAST_CONNECTION=pusher
                                        
                                    

Ensure you replace <your_pusher_app_id>, <your_pusher_app_key>, <your_pusher_app_secret>, and <your_pusher_app_cluster> with the actual values from your Pusher dashboard. Be careful to only replace the placeholder values with your actual credentials and do not alter the keys. This setup will enable your application to connect to Pusher's cloud servers and set Pusher as your broadcast driver.

Setup Twilio

Twilio is a cloud communications platform that enables developers to integrate messaging, voice, video, and other communication capabilities into their applications using APIs. It allows businesses to send SMS, make phone calls, handle VoIP, implement two-factor authentication, and more without needing to build their own telecom infrastructure.

Install the Twilio helper library by running the command below in a new terminal instance or tab.

                                        
                                            composer require twilio/sdk
                                        
                                    

Then, in your Twilio console, to get the account information Acount SID, Auth Token, My Twilio phone number.

Now, locate the .env file in the root directory of your project. Add the following lines to the bottom of the file:

                                        
                                            TWILIO_CONNECTION=
TWILIO_NOTIFICATION_CHANNEL_CONNECTION=
TWILIO_API_SID=
TWILIO_API_AUTH_TOKEN=
TWILIO_API_FROM_NUMBER=
                                        
                                    
  • TWILIO_CONNECTION - The name of the default Twilio API connection for your application; if using a single connection this does not need to be changed

  • TWILIO_NOTIFICATION_CHANNEL_CONNECTION - If using Laravel's notifications system, the name of a Twilio API connection to use in the notification channel (defaulting to your default connection); if using a single connection this does not need to be changed

  • TWILIO_API_SID - The Twilio API SID to use for the default Twilio API connection

  • TWILIO_API_AUTH_TOKEN - The Twilio API authentication token to use for the default Twilio API connection

  • TWILIO_API_FROM_NUMBER - The default sending phone number to use for the default Twilio API connection, note the sending phone number can be changed on a per-message basis

Setup model and migration

After scaffolding the application, the next step is to create a model and a corresponding database migration. This will allow us to store chat messages and user information in the database. Run the following command in your terminal to create a model with a corresponding migration file.

                                        
                                            php artisan make:model -m ChatMessages
                                        
                                    

This command will generate ChatMessages.php in the app/Models directory and a migration file in the database/Migrations directory.

In the migration above, the database schema of the chat application is defined. This schema includes an id for each message, foreign keys sender_id and receiver_id that reference the users table, a text column to store the content of the message, and timestamp columns to record when each message is created and updated.

Open app/Models and update the content of ChatMessages.php with this.

                                        
                                            <?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ChatMessages extends Model
{
    use HasFactory;

    protected $fillable = ['sender_id', 'receiver_id', 'text'];

    public function sender()
    {
        return $this->belongsTo(User::class, 'sender_id');  
    }

    public function receiver()
    {
        return $this->belongsTo(User::class, 'receiver_id');
    }
}
                                        
                                    

In the code above, the $fillable property allows sender_id, receiver_id, and text to be mass-assigned. Additionally, the model defines two many-to-one relationships: the sender relationship links to the user model via the sender_id, and the receiver relationship links to the user model via receiver_id, representing the sender and receiver of the message, respectively.

Next, navigate to database/Migrations and update the file that ends with create_chat_messages_table.php with the following code.

                                        
                                            <?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('chat_messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('sender_id')->constrained('users');
            $table->foreignId('receiver_id')->constrained('users');
            $table->string('text');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('chat_messages');
    }
};
                                        
                                    

Update the file that ends with create_users_table.php with the following code.

                                        
                                            <?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->string('email')->unique();
            $table->timestamp('email_verified_at')->nullable();
            $table->string('password');
            $table->string('phone_number')->nullable();
            $table->rememberToken();
            $table->timestamps();
        });

        Schema::create('password_reset_tokens', function (Blueprint $table) {
            $table->string('email')->primary();
            $table->string('token');
            $table->timestamp('created_at')->nullable();
        });

        Schema::create('sessions', function (Blueprint $table) {
            $table->string('id')->primary();
            $table->foreignId('user_id')->nullable()->index();
            $table->string('ip_address', 45)->nullable();
            $table->text('user_agent')->nullable();
            $table->longText('payload');
            $table->integer('last_activity')->index();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('users');
        Schema::dropIfExists('password_reset_tokens');
        Schema::dropIfExists('sessions');
    }
};
                                        
                                    

In this migration we add a new field to allow add phone_number in the user table.

After updating the models and migration,s the next step is to run the migration. Do that by running the command below in your terminal.

                                        
                                            php artisan migrate
                                        
                                    

Setup the event broadcast

Next, set up the event broadcast by navigating to channels.php file in the routes folder and update it with the following.

                                        
                                            <?php

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('chat', function ($user) {
    return Auth::check();
});
                                        
                                    

In the code above, a new broadcast channel named "chat" is defined, and a closure is passed to ensure that only authenticated users can subscribe to the channel.

After defining the channel, create a new event which will be broadcast to the client on the "chat" channel. Run the command below in your terminal to generate the event class.

                                        
                                            php artisan make:event MessageSent
                                        
                                    

The command will create a new file called MessageSent.php in the app/Events directory. Open the MessageSent.php file and update it with the below content.

                                        
                                            <?php

namespace App\Events;

use App\Models\ChatMessages;
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;
use App\Models\User;

class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $user, $chatMessage;

    /**
     * Create a new event instance.
     */
    public function __construct(User $user, ChatMessages $chatMessage)
    {
        $this->user = $user;
        $this->chatMessage = $chatMessage;
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("chat"),
        ];
    }

    public function broadcastWith()
    {
        return ['message' => $this->chatMessage];
    }
}
                                        
                                    

In the code above, the user and the message are passed into the constructor. The broadcastOn() method specifies that the event will be broadcast on a private channel named "chat", while the broadcastWith() method adds the chat message to the broadcast payload.

Create the controller

The next step is to create a controller class that will contain all the application logic. To create the controller, run the command below in your terminal.

                                        
                                            php artisan make:controller ChatController
php artisan make:controller TwilioControler
                                        
                                    

This command will create a new file called ChatController.php in the app/Http/Controllers folder.

Open the ChatController.php file and replace the content with the following.

                                        
                                            <?php

namespace App\Http\Controllers;

use App\Events\MessageSent;
use App\Models\ChatMessages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Twilio\Rest\Client;
use App\Models\User;
use Illuminate\Support\Facades\Redirect;

class ChatController extends Controller
{
    public function store(User $user, Request $request)
    {
	    $sender = Auth::user();
        $client = new Client(
            env('TWILIO_API_SID'),
            env('TWILIO_API_AUTH_TOKEN')
        );
        $client->messages->create(
            $user->phone_number,
            [
                'from' => $sender->phone_number,
                'body' => $request->message,
            ]
        );
        $message = ChatMessages::create([
            'sender_id' => $sender->id,
            'receiver_id' => $user->id,
            'text' => $request->message,
        ]);
        broadcast(new MessageSent($user, $message))->toOthers();

        return Redirect::route('chat', ['user' => $user]);
    }
}
                                        
                                    

In the code above, the necessary imports required for the chat application are added. In the store() function, a new message from the currently authenticated user to the specified user is saved to the database, and the MessageSent event is broadcasted on the chat channel.

Open the TwilioController.php file and replace the content with the following.

                                        
                                            <?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\ChatMessages;
use App\Models\User;
use App\Events\MessageSent;
use Twilio\TwiML\MessagingResponse as Twiml;

class TwilioController extends Controller
{
	public function handleIncomingSms(Request $request)
    {
        // Get the message content and sender's phone number
        $from = $request->input('From');
        $message = $request->input('Body');
        $to = $request->input('To');

        // Log the incoming message (optional)
        Log::info("SMS from {$from}: {$message}");

        // Send an automatic reply
        $response = new Twiml();
        $response->message("Thank you for your message! We will get back to you soon.");
        $sender = User::where('phone_number', $from)->first();;
        $receiver = User::where('phone_number', $to)->first();
        ChatMessages::create([
            'sender_id' => $sender->id,
            'receiver_id' => $receiver->id,
            'text' => $message,
        ]);
        broadcast(new MessageSent($sender, $message))->toOthers();

        return response($response, 200)->header('Content-Type', 'text/xml');
    }
}
                                        
                                    

In the code above, we are storing the message in the database and sending the notification to update the chat in real time.

Twilio reply setup

Expose your local server to allow recieve incoming messages.

                                        
                                            herd share
                                        
                                    

setup the url on the twilio webhook web store incoming message:

Update the routes

Now, it's time to update the routing table. So, in the web.php file located in the routes folder, add the following imports at the beginning of the file.

                                        
                                            use App\Http\Controllers\ChatController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\TwilioController;
use App\Models\ChatMessages;
use App\Models\User;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
                                        
                                    

Next, replace the dashboard route with the following .

                                        
                                            Route::get('/dashboard', function () {
    return Inertia::render('Dashboard', [
        'auth' => [
            'user' => auth()->user(),
        ],
        'users' => User::where('id', '!=', auth()->id())->get(),
    ]);
})->middleware(['auth', 'verified'])->name('dashboard');

Route::get('/chat/{user}', function (User $user){

    $sender = Auth::user();

    return Inertia::render('Chat', [
        'sender' => $sender,
        'receiver' => $user,
        'messages' => ChatMessages::with(['sender', 'receiver'])
            ->whereIn('sender_id', [$sender->id, $user->id])
            ->whereIn('receiver_id', [$sender->id, $user->id])
            ->get(),
    ]);
})->middleware(['auth', 'verified'])->name('chat');

Route::post('/twilio/incoming-sms', [TwilioController::class, 'handleIncomingSms'])->name('twilio.incoming-sms');

Route::post(
    'messages/{user}',
    [ChatController::class, 'store']
)->middleware(['auth'])->name('messages.store');
                                        
                                    

These routes set up the necessary endpoints for the chat application. The /dashboard route displays a list of users, excluding the current user. The /chat/{user} route loads the chat interface for a selected user. The Route::resource line sets up routes for retrieving and sending messages using the ChatController.

Frontend setup

Now that the backend part of the chat application is fully set up, it’s time to build the user interface of the chat application.

Create a new React component

Next, navigate to resources/js and create a new folder called components. In the newly created components folder, create a new file called ChatBox.vue. Update its content with the following.

import InputError from '@/Components/InputError';
import { Transition } from '@headlessui/react';
import { useForm } from '@inertiajs/react';
import { FormEventHandler, useEffect, useRef, useState } from 'react';

interface Message {
    id: number;
    sender_id: number;
    receiver_id: number;
    text: string;
    created_at: string;
}

interface ChatBoxProps {
    sender: { id: number };
    receiver: { id: number };
    initialMessages: Message[];
}

const ChatBox: React.FC<ChatBoxProps> = ({
    sender,
    receiver,
    initialMessages,
}) => {
    const [messages, setMessages] = useState<Message[]>(initialMessages);
    const messagesBox = useRef<HTMLDivElement>(null);
    const { data, setData, post, errors, processing, recentlySuccessful } =
        useForm({
            message: '',
        });

    const submit: FormEventHandler = (e) => {
        e.preventDefault();

        post(route('messages.store', receiver.id));
        setData('message', '');
        setMessages((prevMessages) => [
            ...prevMessages,
            {
                id: 0,
                sender_id: sender.id,
                receiver_id: receiver.id,
                text: data.message,
                created_at: new Date().toISOString(),
            },
        ]);
    };

    useEffect(() => {
        if (window.Echo) {
            console.log('echo set');
            window.Echo.private('chat').listen(
                'MessageSent',
                (response: { message: Message }) => {
                    console.log('sent', response);
                    if (response.message) {
                        setMessages((prevMessages) => [
                            ...prevMessages,
                            response.message,
                        ]);
                    }
                },
            );
        }
    }, [receiver.id, sender.id]);

    useEffect(() => {
        if (messagesBox.current) {
            messagesBox.current.scrollTop = messagesBox.current.scrollHeight;
        }
    }, [messages]);

    const formatTimestamp = (timestamp: string) => {
        const date = new Date(timestamp);
        return `${date.getHours()}:${date.getMinutes()}`;
    };

    return (
        <div className="mx-auto flex h-[500px] w-full max-w-md flex-col border p-4">
            <div
                ref={messagesBox}
                className="flex flex-1 flex-col overflow-y-auto p-3"
            >
                {messages.map((message, index) => (
                    <div
                        key={index}
                        className={`my-1 max-w-xs rounded-lg p-2 ${
                            message.sender_id === sender.id
                                ? 'self-end bg-blue-100 text-blue-800'
                                : 'self-start bg-gray-200 text-gray-800'
                        }`}
                    >
                        {message.text}
                        <span className="ml-2 text-xs text-gray-500">
                            {formatTimestamp(message.created_at)}
                        </span>
                    </div>
                ))}
            </div>
            <form onSubmit={submit}>
                <div className="flex border-t p-2">
                    <input
                        type="text"
                        value={data.message}
                        onChange={(e) => setData('message', e.target.value)}
                        placeholder="Type a message..."
                        className="flex-1 rounded-lg border p-2"
                    />
                    <InputError className="mt-2" message={errors.message} />
                    <button
                        disabled={processing}
                        className="ml-2 rounded-lg bg-blue-500 px-4 py-2 text-white"
                    >
                        Send
                    </button>
                    <Transition
                        show={recentlySuccessful}
                        enter="transition ease-in-out"
                        enterFrom="opacity-0"
                        leave="transition ease-in-out"
                        leaveTo="opacity-0"
                    >
                        <p className="text-sm text-gray-600 dark:text-gray-400">
                            Saved.
                        </p>
                    </Transition>
                </div>
            </form>
        </div>
    );
};

export default ChatBox;

Update the Echo.js file

After creating the chat components, the next step is to update the JavaScript files. In the resources/js directory, locate the echo.js file and replace the content of the file with the following.

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;
window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    forceTLS: true
});

In the updated echo.js file, Echo is configured to use Pusher as the broadcaster for real-time events. It uses the key and cluster information specified earlier in our .env file to connect the application to the Pusher service.

Update the Blade templates

The final step in the frontend setup is updating the dashboard page. Navigate to resources/js/Pages and update dashboard.tsx with the below content.

import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';

interface User {
    id: number;
    name: string;
    email: string;
}

interface DashboardProps {
    auth: {
        user: User;
    };
    users: User[];
}

export default function Dashboard({ auth, users }: DashboardProps) {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
                    Dashboard
                </h2>
            }
        >
            <Head title={auth.user.name} />

            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800">
                        <div className="p-6 text-gray-900 dark:text-gray-100">
                            <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
                                {users.map((user) => (
                                    <div
                                        key={user.id}
                                        className="overflow-hidden bg-white shadow-sm sm:rounded-lg"
                                    >
                                        <div className="p-6">
                                            <div className="flex items-center">
                                                <a href={`/chat/${user.id}`}>
                                                    <div className="ml-4">
                                                        <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
                                                            {user.name}
                                                        </div>
                                                        <div className="text-sm text-gray-500 dark:text-gray-400">
                                                            {user.email}
                                                        </div>
                                                    </div>
                                                </a>
                                            </div>
                                        </div>
                                    </div>
                                ))}
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );

In the updated Dashboard.tsx, the list of users profiled on the system is returned, such that a logged-in user can chat with any of them.

Also in resources/js/Pages, create a new file Chat.tsx and update it with the following content.

import ChatBox from '@/Components/ChatBox';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head } from '@inertiajs/react';

interface User {
    id: number;
    name: string;
    email: string;
}

interface Message {
    id: number;
    sender_id: number;
    receiver_id: number;
    text: string;
    created_at: string;
}

interface ChatProps {
    receiver: User;
    sender: User;
    messages: Message[];
}
export default function Chat({ receiver, sender, messages }: ChatProps) {
    return (
        <AuthenticatedLayout
            header={
                <h2 className="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
                    Chat
                </h2>
            }
        >
            <Head title={sender.name} />

            <div className="py-12">
                <div className="mx-auto max-w-7xl sm:px-6 lg:px-8">
                    <div className="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800">
                        <div
                            className="p-6 text-gray-900 dark:text-gray-100"
                            id="app"
                        >
                            <ChatBox
                                receiver={receiver}
                                sender={sender}
                                initialMessages={messages}
                            />
                        </div>
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

This file holds the inertia component responsible for the chat functionality. It sets up the layout and includes the Chatbox component, passing the authenticated user as the sender and the selected user as the receiver as props to it.

Test the chat application

It’s finally time to see your chat app in action. By default Laravel events are dispatched on queues, therefore you’ll need to start up a queue worker instance so your event can be processed. Run the command below in your terminal to start your queue.

php artisan queue:listen

Next, register two new users. Log in with one user in a regular browser tab, and log in with the other user in an incognito tab. Then, select each other on both tabs to start chatting. You can also use the SMS of you phone and will received or send the chat sms.

That’s how to build a real-time SMS chat application using Laravel, Pusher, Twilio and, React inertia

This tutorial has equipped you with the knowledge to effectively build a real-time SMS chat application. You learned how to set up the backend logic and event broadcast using WebSockets with Laravel, handle real-time communication with Pusher, and build a dynamic frontend using React inertia to listen for and display events.

With these skills, you can now create more complex real-time applications, or add additional features to your chat app. The possibilities are endless!

Keep building!