Building Text Message Group Chat with the Nexmo SMS API and PHP
Published on May 13, 2021

To exercise the new PHP client library a bit, we're going to build a simple SMS group chat where a user's inbound message is sent to all the other members of the chat. You can follow along here and build it with me, or just clone the Nexmo SMS Group Chat repository and see it in action.

I won't judge if you just clone the repo, really, I won't. Much.

What We're Building

We're going to put together a simple script that:

  • Allows users to text JOIN and their name (e.g. JOIN tlytle) to a phone number where the phone number represents a 'group'.

  • Once joined any message a user sends will be relayed to the rest of the group. And users get any message someone else sends.

  • If a user decides they no longer want to be a part of the group, sending LEAVE will unsubscribe them.

In a follow-up post we'll also put together a simple web interface where they can see a log of the group's messages.

Set Up

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

This tutorial also uses a virtual phone number. To purchase one, go to Numbers > Buy Numbers and search for one that meets your needs.

We'll start with the Nexmo client library, and a Mongo database. Setting up the database is beyond the scope of this tutorial, but there are a few Mongo hosts with free tiers, and setting up a database on one of them should be pretty straightforward. You'll also need the Mongo driver for PHP installed.

You can include that and the Nexmo client library with Composer:

$ composer require nexmo/client 1.0.*@beta
 $ composer require mongodb/mongodb

Defining a simple configuration file will let us keep our API credentials and database connection in a single file.

Here's an example config.php to use as a template:

return [
    'mongo' =--> [
        'uri' =&gt; 'mongodb://user:password@host:port',
        'database' =&gt; 'groupchat'
    'nexmo' =&gt; [
        'key' =&gt; 'key',
        'secret' =&gt; 'secret'

A very simple bootstrap (bootstrap.php) file takes care of autoloading and passes on the configuration file:

$autoloader = require __DIR__ . '/vendor/autoload.php';
$config = require  __DIR__ . '/config.php';
$config['autoloader'] = $autoloader;

return $config;

With those two files in place, we're ready to build our group chat application. ### Handling Inbound Text Messages We'll need a script that accepts [inbound webhooks]( from Nexmo, and processes the message. So, create an `public/inbound.php` and within that include `../bootstrap.php`, create a Nexmo client, and a mongo client. ```blok {"type":"codeBlock","props":{"lang":"text","code":"%3C!--?php%0A$config%20=%20require%20__DIR__%20.%20'/../bootstrap.php';%0A$nexmo%20=%20new%20%5CNexmo%5CClient(new%20%5CNexmo%5CClient%5CCredentials%5CBasic($config%5B'nexmo'%5D%5B'key'%5D,%20$config%5B'nexmo'%5D%5B'secret'%5D));%0A$mongo%20=%20new%20%5CMongoDB%5CClient($config%5B'mongo'%5D%5B'uri'%5D);%0A$db%20=%20$mongo---%3EselectDatabase($config%5B'mongo'%5D%5B'database'%5D);%0A"}} ``` Next, we want to create an inbound message from an inbound request and check that it's valid. The client library provides a simple way to do this. ```blok {"type":"codeBlock","props":{"lang":"text","code":"$inbound%20=%20%5CNexmo%5CMessage%5CInboundMessage::createFromGlobals();%0Aif(!$inbound-&gt;isValid())%7B%0A%20%20%20%20error_log('not%20an%20inbound%20message');%0A%20%20%20%20return;%0A%7D%0A"}} ``` Now that the file is setup you can head to the [Nexmo dashboard]($%7BCUSTOMER_DASHBOARD_URL%7D), and [point a number to that script]( in the `Callback URL` field. ![Phone Number SMS Callback Webhook Settings]( That configures your Nexmo account to make a webhook request to the script whenever a message is sent to that number. If you're developing locally, you'll need to use something like [ngrok]( to create a local tunnel with a public URL the Nexmo platform can reach. Once you've configured the number, you can send it a message - but it won't do anything. So let's reply to the sender with some instructions. The inbound message object created by the client library has a `createReply` method that uses the inbound webhook data to create a reply by flipping the `to` and the `from`. ```blok {"type":"codeBlock","props":{"lang":"text","code":"$nexmo-&gt;message()-&gt;send($inbound-&gt;createReply('Use%20JOIN%20%5Byour%20name%5D%20to%20join%20this%20group.'));%0A"}} ``` Now send a message to your Nexmo number, and you should get a nice quick reply. Because we're using the parameters sent with the inbound webhook, our code does not need to know what number to use as the sender. Why is that important? Now with no additional code or configuration we have a simple autoresponder that supports as many Nexmo numbers - and by extension group chats - as we point to it. ### Processing Commands Before we can really process any `JOIN` commands, we need to know if the user has already interacted with the system. So it's time to write some queries. We'll set things up with a `users` collection. And we'll expect that each document has the `group` property set to the inbound Nexmo number the message was sent to. The `user` will be set to the user's number (the number the message was sent from). ```blok {"type":"codeBlock","props":{"lang":"text","code":"$user%20=%20$db-&gt;selectCollection('users')-&gt;findOne(%5B%0A%20%20%20%20'group'%20=&gt;%20$inbound-&gt;getTo(),%20//%20the%20group's%20number%0A%20%20%20%20'user'%20%20=&gt;%20$inbound-&gt;getFrom()%20//the%20user's%20%20number%0A%5D);%0A"}} ``` Let's add a simple error log so we can troubleshoot if needed: ```blok {"type":"codeBlock","props":{"lang":"text","code":"if($user)%7B%0A%20%20%20%20error_log('found%20user:%20'%20.%20$user%5B'name'%5D);%0A%7D%20else%20%7B%0A%20%20%20%20error_log('no%20user%20found');%0A%7D%0A"}} ``` Since there's no data in the database, any message at this point should log `no user found`. Now that we've code in place for use checking, we can start looking for command keywords. We'll use the first word to check if the user is sending a command. Because the `JOIN` command expects a name as well, we need to parse the message into a single command as the first word, and an optional argument afterward. Using a regular expression to split on any space, and limiting that to 2 elements gives us what we need. With a parsed command, a `switch` will let us act on that first word: ```blok {"type":"codeBlock","props":{"lang":"text","code":"$command%20=%20preg_split('#%5Cs+#',%20$inbound-&gt;getBody(),%202);%0Aswitch(strtolower(trim($command%5B0%5D)))%7B%0A"}} ``` To start, let's check if the expected second argument has been provided as well - at least for new users. If it's not, sending a reply is easy, we'll just move the reply we already have here: ```blok {"type":"codeBlock","props":{"lang":"text","code":"case%20'join';%0A%20%20%20%20error_log('got%20join%20command');%0A%0A%20%20%20%20if(!$user%20&amp;&amp;%20empty($command%5B1%5D))%7B%0A%20%20%20%20%20%20%20%20$nexmo-&gt;message()-&gt;send($inbound-&gt;createReply('Use%20JOIN%20%5Byour%20name%5D%20to%20join%20this%20group.'));%0A%20%20%20%20%20%20%20%20break;%0A%20%20%20%20%7D%0A"}} ``` If it is a new user (no existing user found), and they provided a name (`$command['1']` was not `empty()`) we should setup the basic user data: ```blok {"type":"codeBlock","props":{"lang":"text","code":"if(!$user)%7B%0A%20%20%20%20$user%20=%20%5B%0A%20%20%20%20%20%20%20%20'group'%20=&gt;%20$inbound-&gt;getTo(),%0A%20%20%20%20%20%20%20%20'user'%20=&gt;%20$inbound-&gt;getFrom(),%0A%20%20%20%20%20%20%20%20'actions'%20=&gt;%20%5B%5D%0A%20%20%20%20%5D;%0A%7D%0A"}} ``` And let's not forget that name. Why do we do it _outside_ the new user check? To allow an existing user to update their name using the `JOIN` command, if they provide a new one. Since we're ensuring that new users have that second argument, we know that any new user will have the name set as well: ```blok {"type":"codeBlock","props":{"lang":"text","code":"if(isset($command%5B1%5D))%7B%0A%20%20%20%20$user%5B'name'%5D%20=%20$command%5B1%5D;%0A%7D%0A"}} ``` Since it's a `JOIN` command, we also need to set the user's status to active, and create a log entry for the action. ```blok {"type":"codeBlock","props":{"lang":"text","code":"$user%5B'status'%5D%20=%20'active';%0A$user%5B'actions'%5D%5B%5D%20=%20%5B%0A%20%20%20%20'command'%20=&gt;%20'join',%0A%20%20%20%20'date'%20=&gt;%20new%20%5CMongoDB%5CBSON%5CUTCDatetime(microtime(true))%0A%5D;%0A"}} ``` Now we just need to save (or create) the user. We'll use Mongo's `replaceOne` command and have it insert the document (`upsert`) if needed, and add `break` so we stop processing once the action is taken: ```blok {"type":"codeBlock","props":{"lang":"text","code":"$db-&gt;selectCollection('users')-&gt;replaceOne(%5B%0A%20%20%20%20'group'%20=&gt;%20$inbound-&gt;getTo(),%20//%20the%20group's%20number%0A%20%20%20%20'user'%20%20=&gt;%20$inbound-&gt;getFrom()%20//the%20user's%20%20number%0A%5D,%20$user,%20%5B'upsert'%20=&gt;%20true%5D);%0A%0Aerror_log('added%20user');%0Abreak;%0A"}} ``` `JOIN`ing gets us halfway there, but we still need to allow users to `LEAVE` a group. Like `JOIN` we'll do a bit of logging and check that the user is actually subscribed - they can't really leave if they're not. If they aren't subscribed, we'll just reply with some help. Which, as we've found out, is pretty easy to do: ```blok {"type":"codeBlock","props":{"lang":"text","code":"case%20'leave';%0A%20%20%20%20error_log('got%20leave%20command');%0A%0A%20%20%20%20if(!$user)%7B%0A%20%20%20%20%20%20%20%20$nexmo-&gt;message()-&gt;send($inbound-&gt;createReply('Use%20JOIN%20%5Byour%20name%5D%20to%20join%20this%20group.'));%0A%20%20%20%20%20%20%20%20break;%0A%20%20%20%20%7D%0A"}} ``` If they are subscribing, we need to update the subscription status and log that the action was taken. That's done by changing the `status` property, and appending a new member to the `actions` array. Of course writing this change to the database is also important: ```blok {"type":"codeBlock","props":{"lang":"text","code":"//update%20the%20user's%20status%0A$user%5B'status'%5D%20=%20'inactive';%0A$user%5B'actions'%5D%5B%5D%20=%20%5B%0A%20%20%20%20'command'%20=&gt;%20'leave',%0A%20%20%20%20'date'%20=&gt;%20new%20%5CMongoDB%5CBSON%5CUTCDatetime(microtime(true))%0A%5D;%0A%0A//update%20the%20database%0A$db-&gt;selectCollection('users')-&gt;replaceOne(%5B%0A%20%20%20%20'group'%20=&gt;%20$inbound-&gt;getTo(),%20//%20the%20group's%20number%0A%20%20%20%20'user'%20%20=&gt;%20$inbound-&gt;getFrom()%20//the%20user's%20%20number%0A%5D,%20$user);%0A"}} ``` Once we've removed the user from the group, we should let them know they've left, and how they can join again in the future: ```blok {"type":"codeBlock","props":{"lang":"text","code":"//let%20them%20know%20they've%20left%0A$nexmo-&gt;message()-&gt;send($inbound-&gt;createReply('You%20have%20left.%20Use%20JOIN%20to%20join%20this%20group%20again.'));%0A%0Aerror_log('removed%20user');%0Abreak;%0A"}} ``` SMS Group Chat by Relaying Messages ----------------------------------- Joining and leaving the group taken care of, we now need to handle a user sending a message, not a command. Any message that isn't a command is a message to the group. The logic here is simple, if the user is subscribed and active, their message should be sent to all the _other_ members. We need to check that the user is able to post a message to the group. If we found a user in the database, it means they were at some time subscribed to the group, but we need to check if they've left. If either case isn't true - they're not found in the database, or they're not active in the group - we'll send a quick helpful reply: ```blok {"type":"codeBlock","props":{"lang":"text","code":"default:%0A%20%20%20%20error_log('no%20command%20found');%0A%0A%20%20%20%20if(!$user%20%7C%7C%20'active'%20!=%20$user%5B'status'%5D)%7B%0A%20%20%20%20%20%20%20%20$nexmo-&gt;message()-&gt;send($inbound-&gt;createReply('Use%20JOIN%20%5Byour%20name%5D%20to%20join%20this%20group.'));%0A%20%20%20%20%20%20%20%20break;%0A%20%20%20%20%7D%0A"}} ``` If they are subscribed and active, we create an archive of their message. This contains the text, the group they sent it to, the user themselves (as well as their name, to avoid having to look up the user every time the name is needed), and other meta data. We'll also create an empty `sends` array to log the messages sent to the other users in the group: ```blok {"type":"codeBlock","props":{"lang":"text","code":"error_log('user%20is%20active');%0A%0A$log%20=%20%5B%0A%20%20%20%20'_id'%20%20%20=&gt;%20$inbound-&gt;getMessageId(),%0A%20%20%20%20'text'%20%20=&gt;%20$inbound-&gt;getBody(),%0A%20%20%20%20'date'%20%20=&gt;%20new%20%5CMongoDB%5CBSON%5CUTCDatetime(microtime(true)),%0A%20%20%20%20'group'%20=&gt;%20$inbound-&gt;getTo(),%0A%20%20%20%20'user'%20%20=&gt;%20$inbound-&gt;getFrom(),%0A%20%20%20%20'name'%20%20=&gt;%20$user%5B'name'%5D,%0A%20%20%20%20'sends'%20=&gt;%20%5B%5D%0A%5D;%0A"}} ``` To find all the members that need the message relayed, we query the `users` collection for any users in this specific group that are marked as active. We need to remember to exclude the current user (that's what the `$ne` means, 'not equal'), but it can be handy to remove this for testing purposes: ```blok {"type":"codeBlock","props":{"lang":"text","code":"$members%20=%20$db-&gt;selectCollection('users')-&gt;find(%5B%0A%20%20%20%20'group'%20%20=&gt;%20$inbound-&gt;getTo(),%0A%20%20%20%20'user'%20%20%20=&gt;%20%5B'$ne'%20=&gt;%20$inbound-&gt;getFrom()%5D,%0A%20%20%20%20'status'%20=&gt;%20'active'%0A%5D);%0A"}} ``` Once we have that list we can iterate over it and send a message to each member. We can pass a simple array to the `send()` method (as well as a `Message` object). That array uses the member's number as the `to`, the group's number as the `from`, and we'll add the name of the user that posted the message to the `text` before sending the message. That will return a full message object. We could treat as an array, but it's easier to just use the getter methods to add the message id and the member's number to the send log. ```blok {"type":"codeBlock","props":{"lang":"text","code":"foreach($members%20as%20$member)%20%7B%0A%20%20%20%20$sent%20=%20$nexmo-&gt;message()-&gt;send(%5B%0A%20%20%20%20%20%20%20%20'to'%20%20%20=&gt;%20$member%5B'user'%5D,%0A%20%20%20%20%20%20%20%20'from'%20=&gt;%20$inbound-&gt;getTo(),%0A%20%20%20%20%20%20%20%20'text'%20=&gt;%20$user%5B'name'%5D%20.%20':%20'%20.%20$inbound-&gt;getBody()%0A%20%20%20%20%5D);%0A%0A%20%20%20%20$log%5B'sends'%5D%5B%5D%20=%20%5B%0A%20%20%20%20%20%20%20%20'user'%20=&gt;%20$sent-&gt;getTo(),%0A%20%20%20%20%20%20%20%20'id'%20%20%20=&gt;%20$sent-&gt;getMessageId()%0A%20%20%20%20%5D;%0A%7D%0A"}} ``` Once all the message are sent, we add the new message to the log collection on the database, and we're done processing the inbound messages. ```blok {"type":"codeBlock","props":{"lang":"text","code":"%20%20%20%20$db-&gt;selectCollection('logs')-&gt;insertOne($log);%0A%0A%20%20%20%20error_log('relayed%20message');%0A%20%20%20%20break;%0A%7D%20//%20end%20of%20switch%0A"}} ``` ### Next Steps And with that we've setup a simple script that accepts inbound messages, replies to some of them, and relays others to a group. Centrally the command concept could be extended to more complex and interactive auto-responder bots, the group relay could be turned into two user proxy that only masks the user's numbers, or this could be repurposed as a SMS distribution list that allows anyone to send an inbound message to a group of people. ![Group SMS Chat in the terminal]( Wherever you take it, processing inbound messages and sending outbound messages is an easy task with the PHP client library and Nexmo's API. There's also a bit more to this demo (which you can just clone and run if you want), and we'll build a web interface to our group chat in part two of this tutorial. ### Useful Resources * [Nexmo PHP Client library]( * [Nexmo Inbound SMS Webhook Docs]( * [Nexmo PHP SMS Group Chat Repo on GitHub]( * [ngrok local tunnel]( * [MongoDB Driver]( * [Mongo DB PHP Driver](

Tim LytleVonage Alumni

Ready to start building?

Experience seamless connectivity, real-time messaging, and crystal-clear voice and video calls-all at your fingertips.

Subscribe to Our Developer Newsletter

Subscribe to our monthly newsletter to receive our latest updates on tutorials, releases, and events. No spam.