Hello, Laravel? Communicating with PHP through Phone Calls!

Vector icon of smartphone with weather icon overlay

Twilio is a SaaS application which enables developers to build telephone applications using web technologies. In this two-part series, we will leverage Twilio to build a weather forecast app that is accessed using the telephone system. The backend will be written with the Laravel framework (an exploratory video course is available for purchase here, or in the form of written tutorials here).

In this part, we will create a simple program that will allow a user to call a phone number that we buy from Twilio, enter a zipcode, and receive the current weather forecast. The user can also get the weather for any day of the week via the voice menu prompts. In the second part of this series, we will leverage what was built in this article to allow the user to interact with the app via SMS (text message).

Prerequisites

Development Environment

This article assumes Homestead Improved is installed. It is not necessary to use it, but the commands might differ slightly if you use a different environment. If you are not familiar with Homestead and want to produce similar results as this article aims to produce, please visit this SitePoint article that shows how to set up Homestead, and if you need a crash course in Vagrant, please see this post. Additionally, if this whets your appetite and you feel like exploring PHP development environments in depth, we have a book about that available for purchase.

Dependencies

We will create a new Laravel project and then add the Twilio PHP SDK and Guzzle HTTP client library to the project:

cd ~/Code
composer create-project --prefer-dist laravel/laravel Laravel 5.4.*
cd Laravel
composer require "twilio/sdk:^5.7"
composer require "guzzlehttp/guzzle:~6.0"

Development

Let’s go through all the steps, one by one.

Routes

Open up the routes/web.php file and add the following ones:

Route::group(['prefix' => 'voice', 'middleware' => 'twilio'], function () {
    Route::post('enterZipcode', 'VoiceController@showEnterZipcode')->name('enter-zip');

    Route::post('zipcodeWeather', 'VoiceController@showZipcodeWeather')->name('zip-weather');

    Route::post('dayWeather', 'VoiceController@showDayWeather')->name('day-weather');

    Route::post('credits', 'VoiceController@showCredits')->name('credits');
});

In this app, all requests will be under the /voice path. When Twilio first connects to the app, it will go to /voice/enterZipcode via HTTP POST. Depending on what happens in the telephone call, Twilio will make requests to other endpoints. This includes /voice/zipcodeWeather for providing today’s forecast, /voice/dayWeather, for providing a particular day’s forecast, and /voice/credits for providing information on where the data came from.

Service Layer

We are going to add a service class. This class will hold a lot of the business logic that will be shared between the voice telephone app and the SMS app.

Create a new sub-folder called Services inside the app folder. Then, create a file called WeatherService.php and put the following content into it:

<?php

namespace AppServices;

use IlluminateSupportFacadesCache;
use TwilioTwiml;

class WeatherService
{
}

This is a large file in the project, so we will build it piece by piece. Put the following pieces of code in this section inside our new service class:

    public $daysOfWeek = [
        'Today',
        'Sunday',
        'Monday',
        'Tuesday',
        'Wednesday',
        'Thursday',
        'Friday',
        'Saturday'
    ];

We will use this array to map a day of the week to a number; Sunday = 1, Monday = 2, etc.

    public function getWeather($zip, $dayName)
    {

        $point = $this->getPoint($zip);
        $tz = $this->getTimeZone($point);
        $forecast = $this->retrieveNwsData($zip);
        $ts = $this->getTimestamp($dayName, $zip);

        $tzObj = new DateTimeZone($tz->timezoneId);

        $tsObj = new DateTime(null, $tzObj);
        $tsObj->setTimestamp($ts);

        foreach ($forecast->properties->periods as $k => $period) {
            $startTs = strtotime($period->startTime);
            $endTs = strtotime($period->endTime);

            if ($ts > $startTs and $ts < $endTs) {
                $day = $period;
                break;
            }
        }

        $response = new Twiml();

        $weather = $day->name;
        $weather .= ' the ' . $tsObj->format('jS') . ': ';
        $weather .= $day->detailedForecast;

        $gather = $response->gather(
            [
                'numDigits' => 1,
                'action' => route('day-weather', [], false)
            ]
        );

        $menuText = ' ';
        $menuText .= "Press 1 for Sunday, 2 for Monday, 3 for Tuesday, ";
        $menuText .= "4 for Wednesday, 5 for Thursday, 6 for Friday, ";
        $menuText .= "7 for Saturday. Press 8 for the credits. ";
        $menuText .= "Press 9 to enter in a new zipcode. ";
        $menuText .= "Press 0 to hang up.";

        $gather->say($weather . $menuText);

        return $response;
    }

The getWeather method takes a zipcode with the day of the week and crafts the text of a weather forecast. First, it figures out the reference time for the day requested, and then looks up the weather forecast by doing a foreach over the array of forecast data. After that, it returns a Voice TwiML response. Below is a sample of what is returned:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Gather numDigits="1" action="/voice/dayWeather">
    <Say>
      This Afternoon the 31st: Sunny, with a high near 72. South southwest wind around 8 mph. Press 1 for Sunday, 2 for Monday, 3 for Tuesday, 4 for Wednesday, 5 for Thursday, 6 for Friday, 7 for Saturday. Press 8 for the credits. Press 9 to enter in a new zipcode. Press 0 to hang up.
    </Say>
  </Gather>
</Response>

The <Gather> tag tells Twilio to expect input from the user’s keypad. The numDigits attribute says how many digits to expect. The action attribute says what endpoint to contact next.

    protected function retrieveNwsData($zip)
    {
        return Cache::remember('weather:' . $zip, 60, function () use ($zip) {
            $point = $this->getPoint($zip);

            $point = $point->lat . ',' . $point->lng;
            $url = 'https://api.weather.gov/points/' . $point . '/forecast';

            $client = new GuzzleHttpClient();

            $response = $client->request('GET', $url, [
                'headers' => [
                    'Accept' => 'application/geo+json',
                ]
            ]);

            return json_decode((string)$response->getBody());
        });
    }

The retrieveNwsData method gets the weather forecast data. First, the method checks to see if a copy of the zipcode’s weather forecast is in cache. If not, then the Guzzle HTTP client is used to make an HTTP GET request to the National Weather Service’s (NWS) API endpoint https://api.weather.gov/points/{point}/forecast. To get the geographic point of the zipcode, a call is made to the getPoint method before doing the request to the weather API. The response from the API endpoint is the weather forecast in GeoJSON format. The forecast is for every day and night for a week (with some exceptions we will discuss later); 14 entries in all. We cache the API response for an hour because making the request is slow, plus we do not want to hit the government servers too frequently and get banned.

    protected function getPoint($zip)
    {
        return Cache::remember('latLng:' . $zip, 1440, function () use ($zip) {
            $client = new GuzzleHttpClient();
            $url = 'http://api.geonames.org/postalCodeSearchJSON';

            $response = $client->request('GET', $url, [
                'query' => [
                    'postalcode' => $zip,
                    'countryBias' => 'US',
                    'username' => env('GEONAMES_USERNAME')
                ]
            ]);

            $json = json_decode((string)$response->getBody());

            return $json->postalCodes[0];
        });
    }

The getPoint method maps a zipcode to a geographic point. This is done by using the GeoNames API. The results are cached for a day because using the API is slow.

    protected function getTimeZone($point)
    {
        $key = 'timezone:' . $point->lat . ',' . $point->lng;

        return Cache::remember($key, 1440, function () use ($point) {
            $client = new GuzzleHttpClient();
            $url = 'http://api.geonames.org/timezoneJSON';

            $response = $client->request('GET', $url, [
                'query' => [
                    'lat' => $point->lat,
                    'lng' => $point->lng,
                    'username' => env('GEONAMES_USERNAME')
                ]
            ]);

            return json_decode((string) $response->getBody());
        });
    }

The getTimeZone method is used to get the timezone that a geographic point resides inside. The GeoNames API is also used and the results are cached for a day for the same reasons.

Continue reading %Hello, Laravel? Communicating with PHP through Phone Calls!%


Source: Sitepoint