How to Build an Automated Lone Worker System in PHP using Symfony Workflow & Twilio

May 18, 2020
Written by
Alex Dunne
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

How to Build an Automated Lone Worker System in PHP using Symfony Workflow & Twilio

Automated lone worker systems are imperative for companies that have employees tasked with working in high-risk environments by themselves. In fact, the employer holds a duty of care to ensure that these employees are safe while they work.

Lone worker systems can take many forms. Such forms include apps downloaded on the lone worker’s phone or physical devices to be worn by the lone worker at all times.

In this tutorial, we’re going to look at how we can combine Symfony and it’s Messenger and Workflow Components, along with Twilo’s Programmable Voice and SMS APIs to implement an automated lone worker check-in system.

Throughout the tutorial we’ll cover:

  • How to use Twilio’s PHP SDK to make outbound calls and receive input from the user via Twilio’s Voice API
  • The basics of state machines and how to implement one with Symfony Workflow
  • How to configure and dispatch delayed asynchronous messages with Symfony Messenger

Prerequisites

In order to complete this tutorial you will need the following:

Creating a New Symfony Application

To begin, we will create a new Symfony project. We’ll be using the Symfony binary to generate the project for us, so if you don’t already have it installed, you can follow the installation instructions from the Symfony documentation. Once you have the Symfony binary installed, run the following command in a terminal:

$ symfony new --full lone-worker-automated-check-ins && cd lone-worker-automated-check-ins

We will also install theTwilio PHP SDK, Symfony Messenger Component, Symfony Workflow Component, SncRedisBundle, and Predis packages for later use. Run the following command in a terminal:

$ composer require twilio/sdk symfony/messenger symfony/workflow snc/redis-bundle:^3.2 predis/predis:^1.0

When prompted, run the library recipes to allow them to create the necessary files. You may see the following error after running the recipes:

Redis error message

Don’t worry, the libraries have been installed correctly. This error occurred because the SncRedisBundle library attempted to configure itself but wasn’t supplied with any configuration options. Let’s fix that now.

Open the config/packages/snc_redis.yaml file and replace it with the following:

# config/packages/snc_redis.yaml
snc_redis:
    clients:
        default:
            type: predis
            alias: default
            dsn: "%env(REDIS_URL)%"

The configuration creates a new service named snc_redis.default which returns a Predis client. You may have noticed that we used "%env(REDIS_URL)%" as the dsn. This is a special syntax provided by Symfony that resolves the value at runtime by replacing the value with the matching environment variable.

Environment variables are often different depending on the environment the software is run. For example, in a local development environment the database is most likely found on the developer’s local machine. Whereas, in a production environment the database is most likely to be located on a different machine. As these values can differ, we want to ensure that our local values aren't committed to the version control system.

By default, Symfony provides a .gitignore file with .env.local as one of the entries. This file is where we will store our values that differ per environment including secret API keys. Run the following command in a terminal to create this file:

$ touch .env.local

Inside this file add a REDIS_URL key and set the value to point to your Redis instance:

# .env.local
REDIS_URL=redis://localhost

Finally, alias the Predis client to point to the snc_redis client you created earlier in the config/packages/snc_redis.yaml file. Append the following to your config/services.yaml file:

# config/services.yaml
Predis\Client: '@snc_redis.default'
Predis\ClientInterface: '@Predis\Client'

Setting Up the Twilio SDK

Before we can start using the Twilio SDK, we first need to fetch our Twilio credentials. If you haven't already registered for an account, you cancreate a new account here. Twilio will also provide you with free credits to test the API. Once you have logged in, navigate to the dashboard and you will see your Account SID and Auth Token.

Twilio dashboard

You will also need an active phone number with Voice capabilities. If you don't already have a phone number you can find one here.

We now have all of the data required to communicate with Twilio’s API. Copy your Account SID, Auth Token, and phone number to the .env.local file we created earlier as follows:

# .env.local
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_NUMBER=

Now that you have configured your Twilio credentials to be read, the Twilio client can be configured. Append the following to your config/services.yaml file:

# config/services.yaml
twilio.client:
        class: Twilio\Rest\Client
        arguments: ['%env(TWILIO_ACCOUNT_SID)%', '%env(TWILIO_AUTH_TOKEN)%']

    Twilio\Rest\Client: '@twilio.client'

Building the Form

A lone worker system requires that both the employee who is working alone and their supervisor are contacted. Your application will need to capture the phone numbers of both parties and how frequent check-ins should occur before contact can be made.

To get started, create a new directory named Model in the existing src directory. Once the directory is created, add a new file named CheckInRequest.php in the src/Model directory and insert the following code:

<?php
// src/Model/CheckInRequest.php

namespace App\Model;

class CheckInRequest
{
    /** @var string $loneWorkerName */
    private $loneWorkerName;

    /** @var string $loneWorkerPhoneNumber */
    private $loneWorkerPhoneNumber;

    /** @var string $supervisorPhoneNumber */
    private $supervisorPhoneNumber;

    /** @var integer $checkInFrequency */
    private $checkInFrequency;

    /**
     * @return string
     */
    public function getLoneWorkerName()
    {
        return $this->loneWorkerName;
    }

    /**
     * @param string $loneWorkerName
     */
    public function setLoneWorkerName($loneWorkerName)
    {
        $this->loneWorkerName = $loneWorkerName;
    }

    /**
     * @return string
     */
    public function getLoneWorkerPhoneNumber()
    {
        return $this->loneWorkerPhoneNumber;
    }

    /**
     * @param string $loneWorkerPhoneNumber
     */
    public function setLoneWorkerPhoneNumber($loneWorkerPhoneNumber)
    {
        $this->loneWorkerPhoneNumber = $loneWorkerPhoneNumber;
    }

    /**
     * @return string
     */
    public function getSupervisorPhoneNumber()
    {
        return $this->supervisorPhoneNumber;
    }

    /**
     * @param string $supervisorPhoneNumber
     */
    public function setSupervisorPhoneNumber($supervisorPhoneNumber)
    {
        $this->supervisorPhoneNumber = $supervisorPhoneNumber;
    }

    /**
     * @return int
     */
    public function getCheckInFrequency()
    {
        return $this->checkInFrequency;
    }

    /**
     * @param int $checkInFrequency
     */
    public function setCheckInFrequency($checkInFrequency)
    {
        $this->checkInFrequency = $checkInFrequency;
    }
}

Once you have created the model, create a new Form directory in the src directory. Inside the src/Form directory, create a file named CheckInRequestType.php and insert the following code:

<?php
// src/Form/CheckInRequestType.php

namespace App\Form;

use App\Model\CheckInRequest;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CheckInRequestType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('loneWorkerName', TextType::class, [
                'required' => true,
            ])
            ->add('loneWorkerPhoneNumber', TelType::class, [
                'required' => true,
            ])
            ->add('supervisorPhoneNumber', TelType::class, [
                'required' => true,
            ])
            ->add('checkInFrequency', NumberType::class, [
                'required' => true,
                'label' => 'Check in frequency (in minutes)'
            ])
            ->add('save', SubmitType::class, [
                'label' => 'Create check in'
            ]);
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => CheckInRequest::class,
        ]);
    }
}

You have now created a custom form type and a model to hold the data for that form type. They will now be  used to capture the user’s input inside of a controller.

Create a new controller titled CheckInRequestController.php in the src/Controller directory and insert the following code:

<?php
// src/Controller/CheckInRequestController.php

namespace App\Controller;

use App\Form\CheckInRequestType;
use App\Model\CheckInRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/")
 */
class CheckInRequestController extends AbstractController
{
    /**
     * @Route(name="check_in_request_new")
     * @param Request $request
     * @return Response
     */
    public function newCheckInRequest(Request $request)
    {
        $checkInRequest = new CheckInRequest();

        $form = $this->createForm(CheckInRequestType::class, $checkInRequest);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            return $this->render('check-in/show.html.twig', [
                'name' => $checkInRequest->getLoneWorkerName(),
                'checkInFrequency' => $checkInRequest->getCheckInFrequency()
            ]);
        }

        return $this->render('check-in/new.html.twig', [
            'form' => $form->createView()
        ]);
    }
}

You will need to create the new and show templates as outlined above. To do so, first create a new directory in the existing templates directory named check-in. Then add a new file named new.html.twig to the templates/check-in directory and insert the following code:

{# templates/check-in/new.html.twig #}
{{ form(form) }}

Additionally, create a file named show.html.twig and insert the following code:

{# templates/check-in/show.html.twig #}
<div>
    <h1>
        Check in created for {{ name }}!
    </h1>
    <p>
        You will receive an initial confirmation phone call momentarily.
    </p>
    <p>
        Once you have confirmed we'll check in with you every {{ checkInFrequency }} minutes.
    </p>
</div>

Now navigate to your local site by running symfony server:start in your terminal. If you receive an error, double-check your YAML configurations from earlier in the tutorial.

You should see four inputs with a submit button. Submit this form and you should see something similar to:

Success response from the form

Great! You’re now capturing all of the required information to start the automated check-in process.

Creating the Automated Check-in Workflow

Define the check-in process

When developing any system, it’s important to understand what you’re creating. To better understand how your automated lone worker system will operate, read through this outline of each stage of the automated check-in process:

  • Check-in created
    • A new automated check-in has been created and requires registration confirmation
  • Confirming registration
    • An initial call is placed to the lone worker to confirm they want the check-ins
      • If they confirm, then a message is queued to check-in after X minutes
      • If they decline, then the process ends
  • Waiting for the next check-in
    • The system waits for the queued message to be processed after waiting X minutes
  • Checking in
    • The queued message has been processed and requires a check-in with the lone worker
      • If they answer and select an option to continue, another message is queued to check in again after X minutes
      • If they answer and select an option to finish, end the process
      • If they do not answer, queue a message to try again in two minutes time
  • Waiting for check-in retry
    • The system  waits for the retry message to be processed
  • Checking in retry
    • The retry message has been processed and we need to retry checking in with the loner worker
      • If they answer and select an option to continue, queue another message to check in again after X minutes
      • If they answer and select an option to finish, end the process
      • If they do not answer:
        • Queue a message immediately to send an SMS to the supervisor
        • Queue a message to try again in two minutes time
  •  Finished
    • The lone worker or supervisor selected to end the process

After writing out the process it's become clear that there are quite a few moving parts. Luckily, these parts can be neatly divided into individual states for easier transitioning between the actions required to interact with the lone worker and their supervisor. This type of model is known in programming as a Finite-state machine (FSM).

FSMs are a great tool for describing how a piece of software should operate due to their constrained toolkit and expressive nature. FSMs define each of the possible states a system can be in and which transitions can be used to get from one state to another. By defining the possible transitions upfront, you can ensure that bugs aren’t accidentally introduced while transitioning from creating a check-in to notifying the supervisor of a missed call.

Fortunately for us, Symfony provides a Workflow Component that you will use to model your automated check-in process as an FSM.

Modeling the process

Begin implementing the workflow by creating a new model to hold the state of a lone worker’s check-in. Create a new file named AutomatedCheckIn.php in the src/Model directory and insert the following code:

<?php

namespace App\Model;

class AutomatedCheckIn
{
    /** @var string $loneWorkerName */
    private $loneWorkerName;

    /** @var string $loneWorkerPhoneNumber */
    private $loneWorkerPhoneNumber;

    /** @var string $supervisorPhoneNumber */
    private $supervisorPhoneNumber;

    /** @var integer $checkInFrequency */
    private $checkInFrequency;

    /** @var string $currentState */
    private $currentState;

    public static function fromCheckInRequest(CheckInRequest $checkInRequest) {
        return (new self())
            ->setLoneWorkerName($checkInRequest->getLoneWorkerName())
            ->setLoneWorkerPhoneNumber($checkInRequest->getLoneWorkerPhoneNumber())
            ->setSupervisorPhoneNumber($checkInRequest->getSupervisorPhoneNumber())
            ->setCheckInFrequency($checkInRequest->getCheckInFrequency())
            ->setCurrentState('created');
    }

    /**
     * @return string
     */
    public function getLoneWorkerName()
    {
        return $this->loneWorkerName;
    }

    /**
     * @param string $loneWorkerName
     * @return AutomatedCheckIn
     */
    public function setLoneWorkerName($loneWorkerName)
    {
        $this->loneWorkerName = $loneWorkerName;

        return $this;
    }

    /**
     * @return string
     */
    public function getLoneWorkerPhoneNumber()
    {
        return $this->loneWorkerPhoneNumber;
    }

    /**
     * @param string $loneWorkerPhoneNumber
     * @return AutomatedCheckIn
     */
    public function setLoneWorkerPhoneNumber($loneWorkerPhoneNumber)
    {
        $this->loneWorkerPhoneNumber = $loneWorkerPhoneNumber;

        return $this;
    }

    /**
     * @return string
     */
    public function getSupervisorPhoneNumber()
    {
        return $this->supervisorPhoneNumber;
    }

    /**
     * @param string $supervisorPhoneNumber
     * @return AutomatedCheckIn
     */
    public function setSupervisorPhoneNumber($supervisorPhoneNumber)
    {
        $this->supervisorPhoneNumber = $supervisorPhoneNumber;

        return $this;
    }

    /**
     * @return int
     */
    public function getCheckInFrequency()
    {
        return $this->checkInFrequency;
    }

    /**
     * @param int $checkInFrequency
     * @return AutomatedCheckIn
     */
    public function setCheckInFrequency($checkInFrequency)
    {
        $this->checkInFrequency = $checkInFrequency;

        return $this;
    }

    /**
     * @return string
     */
    public function getCurrentState()
    {
        return $this->currentState;
    }

    /**
     * @param string $currentState
     * @return AutomatedCheckIn
     */
    public function setCurrentState($currentState)
    {
        $this->currentState = $currentState;

        return $this;
    }
}

Most of this model looks similar to the CheckInRequest model you created earlier with one notable exception: the currentState field. We’ll talk about this shortly but first you’ll create the Symfony Workflow.

Open the config/packages/workflow.yaml file and replace the contents with the following:

framework:
   workflows:
       automated_check_in:
           type: 'state_machine'
           supports:
               - App\Model\AutomatedCheckIn
           marking_store:
               type: 'method'
               property: 'currentState'
           initial_marking: created
           places:
               - created
               - confirming_registration
               - waiting_for_next_check_in
               - checking_in
               - waiting_for_check_in_retry
               - checking_in_retry
               - finished
           transitions:
               start_registration_confirmation:
                   from: created
                   to: confirming_registration
               registration_confirmed:
                   from: confirming_registration
                   to: waiting_for_next_check_in
               registration_declined:
                   from: confirming_registration
                   to: finished
               start_check_in:
                   from: waiting_for_next_check_in
                   to: checking_in
               start_check_in_retry:
                   from: waiting_for_check_in_retry
                   to: checking_in_retry
                   name: start_check_in
               check_in_confirmed:
                   from: [checking_in, checking_in_retry]
                   to: waiting_for_next_check_in
               check_in_finished:
                   from: [checking_in, checking_in_retry]
                   to: finished
               check_in_missed:
                   from: checking_in
                   to: waiting_for_check_in_retry
               check_in_retry_missed:
                   from: checking_in_retry
                   to: waiting_for_check_in_retry
                   name: check_in_missed


The configuration above creates a new workflow named automated_check_in. When reading through the places and transitions you may notice that they are remarkably similar to the stages outlined earlier. By representing the process in a state machine you’ve consolidated the logic into a single place rather than having it spread over the codebase.

You may also have noticed that the type has been set to state_machine. This is important as the default workflow type can be in more than one place at the same time, whereas state machines can't. This is crucial for your use case as the system can’t logically be in two or more states at once.

Finally, as mentioned earlier the AutomatedCheckIn currentState property is used to track the current state of a lone worker’s check-in. This is imperative as the current state determines which states the AutomatedCheckIn can transition to next.

Check-in Registration Confirmation

At this point your system has captured the lone worker’s details and modelled the automated check-in process. You can now start communicating with the user and handling the response via Twilio’s Voice API.

Create a new directory named Service in the existing src directory. Inside the src/Service directory create a new file named AutomatedCheckInService.php. Initially, this service is going to be responsible for creating a new AutomatedCheckIn and initiating an outbound call to the lone worker. Replace the contents of the AutomatedCheckInService.php file with the following:

<?php
// src/Service/AutomatedCheckInService.php

namespace App\Service;

use App\Model\AutomatedCheckIn;
use App\Model\CheckInRequest;
use Twilio\Rest\Client;
use Twilio\TwiML\VoiceResponse;

class AutomatedCheckInService
{
    /** @var Client $twilio */
    private $twilio;

    /** @var string $fromNumber */
    private $fromNumber;

    public function __construct(Client $twilio, string $fromNumber)
    {
        $this->twilio = $twilio;
        $this->fromNumber = $fromNumber;
    }

    public function createAutomatedCheckIn(CheckInRequest $checkInRequest)
    {
        return AutomatedCheckIn::fromCheckInRequest($checkInRequest);
    }

    public function startCheckInRegistrationConfirmation(AutomatedCheckIn $checkIn)
    {
        $response = new VoiceResponse();
        $response->say("Hello, " . $checkIn->getLoneWorkerName());

        $this->twilio->calls
            ->create(
                $checkIn->getLoneWorkerPhoneNumber(),
                $this->fromNumber,
                ['twiml' => $response]
            );
    }
}

The Twilio Client parameter will be automatically injected thanks to Symfony’s Autowiring functionality. However, as the $fromNumber is a scalar value it cannot be automatically autoloaded as Symfony doesn’t know what value we want to use. Instead, we have to manually configure the value we want to use. Open your config/services.yaml file and append the following:

App\Service\AutomatedCheckInService:
        arguments:
            $fromNumber: '%env(TWILIO_NUMBER)%'

Symfony will now evaluate the TWILIO_NUMBER environment variable when configuring an instance of the AutomatedCheckInService.

Update the CheckInRequestController to use the AutomatedCheckInService like so:

<?php
// src/Controller/CheckInRequestController.php

namespace App\Controller;

// …
use App\Service\AutomatedCheckInService;
// …

if ($form->isSubmitted() && $form->isValid()) {
    $checkIn = $automatedCheckInService->createAutomatedCheckIn($checkInRequest);

    return $this->render('check-in/show.html.twig', [
        'name' => $checkInRequest->getLoneWorkerName(),
        'checkInFrequency' => $checkInRequest->getCheckInFrequency()
    ]);
}

You’ve now created an AutomatedCheckIn from the details provided by the submitted form. When the user submits the form, the lone worker needs to confirm the registration by answering a phone call and selecting an option. If you refer back to the state machine created earlier, you can see that the system is currently in the created state when a new AutomatedCheckIn is generated.

When the user submits the form, the system needs to transition to the confirming_registration state. This will be accomplished by applying the start_registration_confirmation transition.

As a side-effect of running the start_registration_confirmation transition we want to invoke the startCheckInRegistrationConfirmation method in our AutomatedCheckInService to make the registration confirmation call.

Subscribing to Workflow events

The Workflow Component fires events at numerous stages during a workflow’s lifecycle. You can find a comprehensive list here.

These events allow a developer to block transitions or execute side effects when a transition occurs or when a state is entered or exited.

For your current use case, you’ll want to listen for start_registration_confirmation transitions and in turn initiate the registration confirmation call to the lone worker.

First, create a directory named EventSubscriber in the src directory. Inside the src/EventSubscriber directory create a file named AutomatedCheckInWorkflowSubscriber.php and add the following content:

<?php
// src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php

namespace App\EventSubscriber;

use App\Model\AutomatedCheckIn;
use App\Service\AutomatedCheckInService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Workflow\Event\Event;

class AutomatedCheckInWorkflowSubscriber implements EventSubscriberInterface
{
    /** @var AutomatedCheckInService $automatedCheckInService */
    private $automatedCheckInService;

    public function __construct( AutomatedCheckInService $automatedCheckInService)
    {
        $this->automatedCheckInService = $automatedCheckInService;
    }

    public static function getSubscribedEvents()
    {
        return [
            'workflow.automated_check_in.transition.start_registration_confirmation' => 'onStartRegistrationConfirmation',
        ];
    }

    public function onStartRegistrationConfirmation(Event $event)
    {
        /** @var AutomatedCheckIn $checkIn */
        $checkIn = $event->getSubject();

        $this->automatedCheckInService->startCheckInRegistrationConfirmation($checkIn);
    }
}

You can see inside the getSubscribedEvents method that we’re listening to the workflow.automated_check_in.transition.start_registration_confirmation event. This event name is generated by the Workflow component and follows the format workflow.[workflow name].transition.[transition name]. In our case the workflow is named automated_check_in and the transition name we’re interested in is start_registration_confirmation.

Applying state transitions

Now you’ll return back to the CheckInRequestController and apply the start_registration_confirmation transition to the lone worker’s AutomatedCheckIn.

Open the src/Controller/CheckInRequestController.php file and modify it like so:

<?php
// src/Controller/CheckInRequestController.php

// ...
use Symfony\Component\Workflow\Registry;
// …

public function newCheckInRequest(
    Request $request, Registry $registry,
    AutomatedCheckInService $automatedCheckInService
)
{
    // ...

    if ($form->isSubmitted() && $form->isValid()) {
        $checkIn = $automatedCheckInService->createAutomatedCheckIn($checkInRequest);

        $checkInWorkflow = $registry->get($checkIn, 'automated_check_in');
        $checkInWorkflow->apply($checkIn, 'start_registration_confirmation');

        return $this->render('check-in/show.html.twig', [
            'name' => $checkInRequest->getLoneWorkerName(),
            'checkInFrequency' => $checkInRequest->getCheckInFrequency()
        ]);
    }

    // ...
}

// ...

If you resubmit the form again you should receive a phone call that says “Hello” followed by the name you provided.

Great! You’ve set up a Symfony workflow and used an EventSubscriber to react to changes inside that workflow.

Before you proceed, let’s discuss how to save the AutomatedCheckIn so that they can be accessed by future requests. For this portion of the tutorial you’ll be using Redis to cache the check-ins and you’ll use the lone worker’s phone number as the key.

Head back to the AutomatedCheckInService and make the following changes:

<?php
// src/Service/AutomatedCheckInService.php

// …

use Predis\ClientInterface;

// ...


/** @var ClientInterface $redis */
private $redis;

public function __construct(Client $twilio, ClientInterface $redis, string $fromNumber)
{
    $this->twilio = $twilio;
    $this->redis = $redis;
    $this->fromNumber = $fromNumber;
}

public function createAutomatedCheckIn(CheckInRequest $checkInRequest)
{
    $checkIn = AutomatedCheckIn::fromCheckInRequest($checkInRequest);
    
    $this->storeAutomatedCheckIn($checkIn);
    
    return $checkIn;
}

public function storeAutomatedCheckIn(AutomatedCheckIn $checkIn)
{
    $this->redis->set($checkIn->getLoneWorkerPhoneNumber(), serialize($checkIn));
}

public function fetchAutomatedCheckIn($loneWorkerPhoneNumber)
{
    $checkIn = $this->redis->get($loneWorkerPhoneNumber);

    return unserialize($checkIn);
}

// ...

You will now be able to  leverage the AutomatedCheckInWorkflowSubscriber created earlier by creating a listener for entered events. This event is broadcast whenever an object has successfully entered a different state. When this listener is triggered the system can call the storeAutomatedCheckIn created above to automatically save the AutomatedCheckIn.

Open the src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php file and make the following changes:

<?php

// …

public static function getSubscribedEvents()
{
    return [
        'workflow.automated_check_in.entered' => 'onStateEntered',
        'workflow.automated_check_in.transition.start_registration_confirmation' => 'onStartRegistrationConfirmation',
    ];
}

public function onStateEntered(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->automatedCheckInService->storeAutomatedCheckIn($checkIn);
}

// ...

That’s it! You don’t have to worry about saving the AutomatedCheckIn every time a transition is applied. The saving is contained in a logical location and is automatically executed whenever the state is changed.

Interacting with Twilio via Webhooks

So far the code you’ve written allows Twilio to greet the user. The code will now be updated to inform the user how frequently the system will be checking in and give them the options to accept or decline the registration.

Twilio determines what to do when interacting with a user by sending HTTP requests to our application via webhooks. It expects a response in TwiML (the Twilio Markup Language) format.

TwiML is an XML document with special tags defined by Twilio. The Twilio PHP SDK provides a programmatic way to generate TwiML so that developers don’t have to worry too much about semantics.

You’ve actually already interacted with this part of the SDK earlier by using the VoiceResponse class of the SDK. We’ll take another look at it again shortly.

Now that you understand Twilio’s requirement for us to provide endpoints to interact with, create a new file named TwilioWebhooksController.php in the src/Controller directory. Replace the contents of the src/Controller/TwilioWebhooksController.php file with the following content:

<?php
// src/Controller/TwilioWebhooksController.php

namespace App\Controller;

use App\Service\AutomatedCheckInService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Twilio\TwiML\VoiceResponse;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Routing\RouterInterface;

/**
 * @Route("/webhook/twilio")
 */
class TwilioWebhooksController extends AbstractController
{
    /**
     * @Route("/registration/voice", name="webhook.twilio.check_in_registration.voice")
     * @param Request $request
     * @param AutomatedCheckInService $automatedCheckInService
     * @return Response
     */
    public function checkInRegistrationVoice(Request $request, AutomatedCheckInService $automatedCheckInService)
    {
        $checkIn = $automatedCheckInService->fetchAutomatedCheckIn($request->request->get('To'));

        if (!$checkIn) {
            $voiceResponse = new VoiceResponse();
            $voiceResponse->say("Sorry, I couldn't find any automated check-ins requested for this number.");

            return new Response($voiceResponse);
        }

        $voiceResponse = new VoiceResponse();
        $gather = $voiceResponse->gather([
            'numDigits' => 1,
            'action' => $this->generateUrl('webhook.twilio.check_in_registration.gather')
        ]);
        $gather->say(
            sprintf(
                'Hello %s. Automated check in calls every %s minutes have been requested. To confirm, press 1. To decline, press 2.',
                $checkIn->getLoneWorkerName(),
                $checkIn->getCheckInFrequency()
            )
        );

        // If the user does not provide an answer then loop
        $voiceResponse->redirect($this->generateUrl('webhook.twilio.check_in_registration.voice', [], RouterInterface::ABSOLUTE_URL));

        return new Response($voiceResponse);
    }

    /**
     * @Route("/registration/voice", name="webhook.twilio.check_in_registration.gather")
     * @param Request $request
     * @param AutomatedCheckInService $automatedCheckInService
     * @param Registry $registry
     * @return Response
     */
    public function checkInRegistrationGather(Request $request, AutomatedCheckInService $automatedCheckInService, Registry $registry)
    {
        $checkIn = $automatedCheckInService->fetchAutomatedCheckIn($request->request->get('To'));

        $voiceResponse = new VoiceResponse();

        if (!$checkIn) {
            $voiceResponse->say("Sorry, I couldn't find any automated check-ins requested for this number.");
            return new Response($voiceResponse);
        }

        $selectedMenuItem = $request->request->get('Digits');

        if (!$selectedMenuItem) {
            // No input was selected, ask them again
            $voiceResponse->redirect($this->generateUrl('webhook.twilio.check_in_registration.voice', [], RouterInterface::ABSOLUTE_URL));
            return new Response($voiceResponse);
        }
        $workflow = $registry->get($checkIn, 'automated_check_in');

        switch ($selectedMenuItem) {
            case 1:
                $workflow->apply($checkIn, 'registration_confirmed');
                $voiceResponse->say(sprintf(
                    'Registration confirmed. Your first check in will be in %s minutes',
                    $checkIn->getCheckInFrequency()
                ));
                break;
            case 2:
                $workflow->apply($checkIn, 'registration_declined');
                $voiceResponse->say('Registration declined. Have a nice day.');
                break;
            default:
                $voiceResponse->say("Sorry, I don't understand that choice.");
                $voiceResponse->redirect($this->generateUrl('webhook.twilio.check_in_registration.voice', [], RouterInterface::ABSOLUTE_URL));
        }

        return new Response($voiceResponse);
    }
}

Let’s break this down. The code above split the communication voice request and the communication response handling into two separate routes.

Consider the webhook.twilio.check_in_registration.voice route first. This route is responsible for greeting the user, providing context about the check-ins, and offering two options; one to accept the check-ins and another to decline. The code also instructs Twilio to take one digit of input to capture if the user wants to accept or decline. When the user provides a response by using the Numpad on their phone, Twilio sends the response to the webhook.twilio.check_in_registration.gather route.

The gather route is responsible for processing the user’s input. In this case, you’re using it to decide which transition to apply. If the user accepts the confirmation then the system applies the registration_confirmed transitions. Alternatively, if the user declines the confirmation, then the registration_declined transition is applied.

Referring back to the workflow created earlier, you can see that the registration_declined transition moves the state to finished because there are no further transitions. On the other hand, the registration_confirmed transition moves the state to waiting_for_next_check_in.

Before we test this, you need to make a slight change to the AutomatedCheckInService startCheckInRegistrationConfirmation method. Swap out the current greeting with a redirect to the webhook.twilio.check_in_registration.voice route you just created.

Open the src/Service/AutomatedCheckInService.php file and make the following changes:

<?php
// src/Service/AutomatedCheckInService.php

// …
use Symfony\Component\Routing\RouterInterface;
// …

/** @var RouterInterface $router */
private $router;

public function __construct(
    Client $twilio, ClientInterface $redis,
    RouterInterface $router, $fromNumber
)
{
    $this->twilio = $twilio;
    $this->redis = $redis;
    $this->router = $router;
    $this->fromNumber = $fromNumber;
}

public function startCheckInRegistrationConfirmation(AutomatedCheckIn $checkIn)
{
    $voiceUrl = $this->router->generate(
        'webhook.twilio.check_in_registration.voice',
        [],
        RouterInterface::ABSOLUTE_URL
    );
    
    $this->twilio->calls
        ->create(
            $checkIn->getLoneWorkerPhoneNumber(),
            $this->fromNumber,
            ['url' => $voiceUrl]
        );
}

As Twilio is going to be making requests to your application, you need to use ngrok to proxy external connections to your local server. In a terminal run the following command:

$ ngrok http <symfony_server_port>

When the ngrok tunnel is created, copy the Forwarding URL and set it as the value to the router.request_context.host key in config/services.yaml like so:

# config/services.yaml
parameters:
    router.request_context.host: <your_ngrok_base_url_here>

Now that Twilio can communicate with your local server, navigate to your ngrok url in a web browser and submit the form. This time you should be greeted and told how frequent the check-ins will be. If you accept the check-ins and look at the terminal window running the symfony server, you should see a Redis SET command with the phone number you provided as well as the currentState field set to waiting_for_next_check_in.

redis log

You’re now at a point where you’re initiating an outbound call and processing the user’s response using TwiML and webhooks.

Dispatching Delayed Asynchronous Messages

Right now when the lone worker accepts the automated check-in the system is not actually checking-in with them. Let’s fix that. PHP does not have long-running processes like other languages such as JavaScript’s Node.js or Elixir. Therefore, to perform an operation in the future you have to add it to a queue. For Symfony, developers can use the Symfony Messenger Component to dispatch messages and handle them in the background later on.

The Symfony Messenger Component consists of two main elements: the message class and the message handler class. The message class is a container for the data that will be passed to your background worker. The data stored in this class must be serializable so that it can be transferred correctly. The message handler class is responsible for consuming the message and performing a task. When dispatching messages Symfony provides a way to augment how the message is handled in the form of envelopes and stamps. In the case of this tutorial, you’ll want to use a DelayStamp so that the message is handled based on the value the user submitted in the form rather than straight away.

First, create a message class by creating a new directory named Message in the src directory. Inside the src/Message directory, create a new file named CheckInWithLoneWorker.php. Replace the contents of the file with the following code:

<?php
// src/Message/CheckInWithLoneWorker.php
namespace App\Message;

class CheckInWithLoneWorker
{
    /** @var string $automatedCheckInPhoneNumber */
    private $automatedCheckInPhoneNumber;

    public function __construct($automatedCheckInPhoneNumber)
    {
        $this->automatedCheckInPhoneNumber = $automatedCheckInPhoneNumber;
    }

    /**
     * @return string
     */
    public function getAutomatedCheckInPhoneNumber()
    {
        return $this->automatedCheckInPhoneNumber;
    }
}

Next you’ll need to create the message handler. Create a new directory named MessageHandler in the src directory. Inside the src/MessageHandler directory, create a new file named CheckInWithLoneWorkerHandler.php. Replace the contents of the file with the following code:

<?php
// src/MessageHandler/CheckInWithLoneWorkerHandler.php
namespace App\MessageHandler;

use App\Message\CheckInWithLoneWorker;
use App\Model\AutomatedCheckIn;
use App\Service\AutomatedCheckInService;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Workflow\Registry;
use Symfony\Component\Workflow\Workflow;

class CheckInWithLoneWorkerHandler implements MessageHandlerInterface
{
    /** @var AutomatedCheckInService $automatedCheckInService */
    private $automatedCheckInService;

    /** @var Workflow */
    private $workflow;

    public function __construct(AutomatedCheckInService $automatedCheckInService, Registry $registry)
    {
        $this->automatedCheckInService = $automatedCheckInService;
        $this->workflow = $registry->get(new AutomatedCheckIn(), 'automated_check_in');
    }

    public function __invoke(CheckInWithLoneWorker $checkInWithLoneWorker)
    {
        $checkIn = $this->automatedCheckInService
            ->fetchAutomatedCheckIn($checkInWithLoneWorker->getAutomatedCheckInPhoneNumber());

        $this->workflow->apply($checkIn, 'start_check_in');
    }
}

You will also need to configure the async transport so that Symfony knows to use that rather than process the message immediately. Open the config/packages/messenger.yaml file and replace it with the following content:

framework:
    messenger:
        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'
        routing:
            'App\Message\CheckInWithLoneWorker': async

At this point, the transport type has not been explicitly defined. Symfony decides which transport to use by processing the MESSENGER_TRANSPORT_DSN environment variable supplied above. You’re going to be using Redis in this tutorial as it supports the DelayStamp and is already set up for caching the lone worker details.

Open the .env.local file we created earlier and append the following line:

MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages

It's important to note that any newly dispatched messages are added to a queue, but won’t immediately be processed. To consume and process the messages, open a terminal and run the following command:

$ php bin/console messenger:consume async

NOTE: If you change the contents of any message handler such as the src/MessageHandler/CheckInWithLoneWorkerHandler.php class you'll need to restart the consumer.

The registration_confirmed transition you applied earlier will need to be used as a response to the user accepting the registration. In the next step, you’ll also lay the groundwork for handling the start_check_in transition as they are closely linked.

Open the src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php file and make the following changes:

<?php
// src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php
// …

use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DelayStamp;

// …

public const MILLISECONDS_IN_A_MINUTE = 60000;

// …

/** @var MessageBusInterface $bus */
private $bus;

public function __construct(AutomatedCheckInService $automatedCheckInService, MessageBusInterface $bus)
{
    $this->automatedCheckInService = $automatedCheckInService;
    $this->bus = $bus;
}

// ...

public static function getSubscribedEvents()
{
    return [
        // ...
        'workflow.automated_check_in.transition.registration_confirmed' => 'onRegistrationConfirmed',
        'workflow.automated_check_in.transition.start_check_in' => 'onStartCheckIn',
    ];
}

// ...

public function onRegistrationConfirmed(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->bus->dispatch(new CheckInWithLoneWorker($checkIn->getLoneWorkerPhoneNumber()), [
        new DelayStamp($checkIn->getCheckInFrequency() * self::MILLISECONDS_IN_A_MINUTE)
    ]);
}

public function onStartCheckIn(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->automatedCheckInService->startCheckIn($checkIn);
}

You’ll now need to implement the startCheckIn function. Head over to the AutomatedCheckInService and implement the method like so:

<?php
// src/Service/AutomatedCheckInService.php
// …
public function startCheckIn(AutomatedCheckIn $checkIn)
{
    $voiceUrl = $this->router->generate(
        'webhook.twilio.check_in.gather,
        [],
        RouterInterface::ABSOLUTE_URL
    );

    $this->twilio->calls
        ->create(
            $checkIn->getLoneWorkerPhoneNumber(),
            $this->fromNumber,
            ['url' => $voiceUrl]
        );
}

Let’s review the changes you’ve made. As a side-effect to the registration_confirmation transition, a CheckInWithLoneWorker message has been dispatched using a DelayStamp with the delay supplied by the form. If you keep an eye on the terminal window that is running, you’ll see activity when the delay has elapsed from the message consumer command.

Right now, when the delayed message is consumed, you’ll see an exception that the check-in route does not currently exist. This means that you’ve now successfully merged Symfony Workflow with Symfony Messenger by dispatching delayed asynchronous messages as a side-effect of the workflow transitions between two states!

Now to implement the missing check-in route.

Open the src/Controller/TwilioWebhooksController.php file and add the following two methods:

<?php
// src/Controller/TwilioWebhooksController.php
// ...
/**
  * @Route("/check-in/gather", name="webhook.twilio.check_in.gather")
  * @param Request $request
  * @param AutomatedCheckInService $automatedCheckInService
  * @param Registry $registry
  * @return Response
  */
public function checkInGather(
    Request $request, AutomatedCheckInService $automatedCheckInService,
    Registry $registry
)
{
    $checkIn = $automatedCheckInService->fetchAutomatedCheckIn($request->request->get('To'));

    $voiceResponse = new VoiceResponse();

    if (!$checkIn) {
        $voiceResponse->say("Sorry, I couldn't find any automated check-ins requested for this number.");
        return new Response($voiceResponse);
    }

    $selectedMenuItem = $request->request->get('Digits');

    if (!$selectedMenuItem) {
        // No input was selected, ask them again
        $voiceResponse->redirect($this->generateUrl('webhook.twilio.check_in.gather, [], RouterInterface::ABSOLUTE_URL));
        return new Response($voiceResponse);
    }

    $workflow = $registry->get($checkIn, 'automated_check_in');

    switch ($selectedMenuItem) {
        case 1:
            $workflow->apply($checkIn, 'check_in_confirmed');
            $voiceResponse->say(sprintf(
                'Your next check in will be in %s minutes.',
                $checkIn->getCheckInFrequency()
            ));
            break;
        case 2:
            $workflow->apply($checkIn, 'check_in_finished');
            $voiceResponse->say('Check-ins cancelled. Have a nice day.');
            break;
        default:
            $voiceResponse->say("Sorry, I don't understand that choice.");
            $voiceResponse->redirect($this->generateUrl('webhook.twilio.check_in.gather, [], RouterInterface::ABSOLUTE_URL));
    }

    return new Response($voiceResponse);
}

The new check-in routes follow the same pattern as the registration routes. The voice route is called by Twilio so that it knows what to do when the user answers the call. The gather route is then called when the user provides input. Every time the user receives a check-in, the system lets them decide if they want to continue receiving the check-ins or stop.

Referring back to the workflow, if the user decides they no longer need check-ins then they are transitioned to the finished state and the process ends. However, if the user wishes to keep receiving the check-ins the system needs to queue another check-in message. Let’s update the AutomatedCheckInWorkflowSubscriber to handle this transition:

<?php
// src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php

..
use App\Message\CheckInWithLoneWorker;
..

public static function getSubscribedEvents()
{
    return [
        // ...
        'workflow.automated_check_in.transition.check_in_confirmed' => 'onCheckInConfirmed',
    ];
}

// …

public function onCheckInConfirmed(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->bus->dispatch(new CheckInWithLoneWorker($checkIn->getLoneWorkerPhoneNumber()), [
        new DelayStamp($checkIn->getCheckInFrequency() * self::MILLISECONDS_IN_A_MINUTE)
    ]);        
}

Now, if you resubmit the form and accept the check-ins you will keep receiving calls at the interval you provided until you select the finish option.

Before you finish, let’s handle the case when a user does not answer the check-in call.

Tracking the Status of an Outbound Call

A crucial part of this lone worker check-in system is reacting to a lone worker not answering their check-in call. The reason for the missed call could be something as simple as a toilet break to something more serious like being trapped at a tall height. Rather than escalate the call immediately to the supervisor with a false positive, the system will instead retry the check-in after two minutes.

Twilio’s Voice API enables us to track the status of an outbound call via StatusCallbacks. These callbacks are triggered at multiple points during the lifecycle of an outbound call. Notably, along with each of the callbacks, Twilio also provides the CallStatus. The call status provides auxiliary information such as if the receiving number was busy, if the call failed, or if there was no-answer. This information is exactly what we need for our lone worker missed check-in use case.

It’s important to note that if the user has their voicemail enabled, Twilio will set the CallStatus of the outgoing call to completed as the voicemail machine technically answered the call. Fortunately, Twilio also provides us an option to detect if the phone call was answered by a human or a machine.

Now that you know how to track the status of an outbound call, your application can start handling a missed check-in by a lone worker. You’ll need to request the completed StatusCallback from Twilio and also enable machineDetection. On receiving the callback event, you need to check if the CallStatus is busy, failed, or no-answer, or if the call was answered by a machine. In the event of any of those CallStatuses or a machine answer, you’ll need to queue another check-in message with a two-minute delay.

First, you’re going to add the StatusCallback route that Twilio will call with the StatusCallbackEvents. Open the TwilioWebhooksController class and add the following route:

<?php
// src/Controller/TwilioWebhooksController.php
// ...

/**
  * @Route("/event", name="webhook.twilio.handle_status_callback_event")
  * @param Request $request
  * @param AutomatedCheckInService $automatedCheckInService
  * @param Registry $registry
  * @return Response
  */
public function handleStatusCallbackEvent(
    Request $request, AutomatedCheckInService $automatedCheckInService,
    Registry $registry
)
{
    $to = $request->request->get('To');
    $callStatus = $request->request->get('CallStatus');
    $answeredBy = $request->request->get('AnsweredBy');

    $checkIn = $automatedCheckInService->fetchAutomatedCheckIn($to);

    if (!$checkIn) {
        return new Response();
    }

    if (!in_array($callStatus, ['busy', 'failed', 'no-answer', 'completed'])) {
        return new Response();
    }

    if ($callStatus === 'completed' && $answeredBy === 'human') {
        return new Response();
    }

    $workflow = $registry->get($checkIn, 'automated_check_in');
    $workflow->apply($checkIn, 'check_in_missed');

    return new Response();
}

With the check_in_missed transition applied your system can now add a new listener to queue a delayed check-in attempt. Open the AutomatedCheckInWorkflowSubscriber class and make the following changes:

<?php
// src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php
// …

public const CHECK_IN_RETRY_DELAY = 2 * self::MILLISECONDS_IN_A_MINUTE;

// ...

public static function getSubscribedEvents()
{
    return [
        // ...
        'workflow.automated_check_in.transition.check_in_missed' => 'onCheckInMissed',
    ];
}

// ...

public function onCheckInMissed(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->bus->dispatch(new CheckInWithLoneWorker($checkIn->getLoneWorkerPhoneNumber()), [
        new DelayStamp(self::CHECK_IN_RETRY_DELAY)
    ]);
}

If you refer back to the automated_check_in workflow YAML configuration, you can see that the application uses a name key in the start_check_in_retry transition to override the name of the transition to be start_check_in. This is done because the start_check_in transition already exists, but for a different from state. Rather than creating a different transition with the same intent, you can instead use the name key to reuse the transition name. This enables you to use the start_check_in transition if the AutomatedCheckIn is in either the waiting_for_next_check_in or the waiting_for_check_in_retry state.

This also simplifies the code that applies transitions such as the CheckInWithLoneWorkerHandler. Rather than inspecting the current state of the AutomatedCheckIn to decide which transition to apply, the handler can instead apply the start_check_in regardless. This is another important aspect of a FSM. The transitions should describe what you want the machine to do without needing to know the inner workings of the machine itself.

You will also need to tell Twilio which URL to send the StatusCallbackEvents to, which events the system is expecting, and whether a human or a machine answered the call. Open your AutomatedCheckInService class and modify it like so:

<?php
// src/Service/AutomatedCheckInService.php
// …

public function startCheckIn(AutomatedCheckIn $checkIn)
{
    $voiceUrl = $this->router->generate(
        'webhook.twilio.check_in.gather,
        [],
        RouterInterface::ABSOLUTE_URL
    );

    $statusCallbackUrl = $this->router->generate(
        'webhook.twilio.handle_status_callback_event',
        [],
        RouterInterface::ABSOLUTE_URL
    );

    $this->twilio->calls
        ->create(
            $checkIn->getLoneWorkerPhoneNumber(),
            $this->fromNumber,
            [
                'url' => $voiceUrl,
                'statusCallback' => $statusCallbackUrl,
                'statusCallbackEvent' => ['completed'],
                'statusCallbackMethod' => 'POST',
                'machineDetection' => 'Enable',
            ]
        );
}

The code above supplies Twilio with the URL to send updates to and specifies that we’re only interested in completed events. The code will also try to detect if the answerer is human or a machine, which lets you know if the lone worker answered the call or if it was their voicemail.

Note: Make sure you reload your message consumer before continuing.

You can verify the check-in retries work by submitting the form, accepting the check-ins and then ignoring the check-in and following check-in retries. You’ll see that you are called every two minutes indefinitely!

Before we finish we’re going to utilize the supplied supervisor’s phone number by sending them an SMS every time the user misses a check-in using Twilio’s SMS API. To ensure that any issues whilst sending the SMS do not interfere with the check-in cycle you’re going to create a new Message and MessageHandler, and process the SMS sending in a separate process in the background.

Create a new file named NotifySupervisor.php in the src/Message directory and replace it with the following code:

<?php
// src/Message/NotifySupervisor.php
namespace App\Message;

class NotifySupervisor
{
    /** @var string $phoneNumber */
    private $phoneNumber;

    /** @var string$loneWokerName */
    private $loneWorkerName;

    public function __construct($phoneNumber, $loneWorkerName)
    {
        $this->phoneNumber = $phoneNumber;
        $this->loneWorkerName = $loneWorkerName;
    }

    /**
     * @return string
     */
    public function getPhoneNumber()
    {
        return $this->phoneNumber;
    }

    /**
     * @return string
     */
    public function getLoneWorkerName()
    {
        return $this->loneWorkerName;
    }
}

Create the corresponding MessageHandler by creating a file named NotifySupervisorHandler.php in the src/MessageHandler directory. Replace the contents of the file with the following:

<?php
// src/MessageHandler/NotifySupervisorHandler.php
namespace App\MessageHandler;

use App\Message\NotifySupervisor;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Twilio\Rest\Client;

class NotifySupervisorHandler implements MessageHandlerInterface
{
    /** @var Client $twilio */
    private $twilio;

    /** @var string $fromNumber */
    private $fromNumber;

    public function __construct(Client $twilio, string $fromNumber)
    {
        $this->twilio = $twilio;
        $this->fromNumber = $fromNumber;
    }

    public function __invoke(NotifySupervisor $notifySupervisor)
    {
        $loneWorkerName = $notifySupervisor->getLoneWorkerName();
        
        $this->twilio->messages->create($notifySupervisor->getPhoneNumber(), [
            'from' => $this->fromNumber,
            'body' => "Alert. $loneWorkerName has missed a check-in."
        ]);
    }
}

Similar to the AutomatedCheckInService, the $fromNumber needs to be manually configured as it is a scalar value. Append the following to the config/services.yaml file:

# config/services.yaml
App\MessageHandler\NotifySupervisorHandler:
        arguments:
            $fromNumber: '%env(TWILIO_NUMBER)%'

Before the messages are dispatched, Symfony needs to handle dispatched NotifySupervisor messages asynchronously as defined earlier with the CheckInWithLoneWorker messages. Add 'App\Message\NotifySupervisor': async underneath the routing key in your config/packages/messenger.yaml file.

Finally, your system can dispatch the notification every time the check_in_missed transition is applied. As you already have a listener for that event in your AutomatedCheckInWorkflowSubscriber, you can dispatch the message there. Update the onCheckInMissed function like so:

<?php
// src/EventSubscriber/AutomatedCheckInWorkflowSubscriber.php
// …
use App\Message\NotifySupervisor;

public function onCheckInMissed(Event $event)
{
    /** @var AutomatedCheckIn $checkIn */
    $checkIn = $event->getSubject();

    $this->bus->dispatch(new CheckInWithLoneWorker($checkIn->getLoneWorkerPhoneNumber()), [
        new DelayStamp(self::CHECK_IN_RETRY_DELAY)
    ]);

    $this->bus->dispatch(
        new NotifySupervisor(
            $checkIn->getSupervisorPhoneNumber(),
            $checkIn->getLoneWorkerName()
        )
    );
}

Note: Make sure you reload your message consumer before continuing.

Testing

Make sure that your server is running the correct processes with the following commands in separate terminals:

$ symfony server:start
$ php bin/console messenger:consume async

Try submitting the form again, registering for check-in, and ignoring the check-ins. The number you supplied as the supervisor’s phone number should be notified every time you miss a check-in. To stop receiving messages and check-ins, answer your next check-in and select the cancel option.

Conclusion

Congratulations! You have successfully used Twilio’s Programmable Voice API, Symfony, Symfony’s Messenger Component, Symfony’s Workflow Component, and Redis to implement a basic lone worker check-in system. You should now understand the basics of a Finite-state machine and how to use Symfony’s Workflow Component to implement one. You should also understand how to use Twilio’s PHP Programmable Voice API to speak to and obtain input from users.

If you want to extend this tutorial, I recommend creating a dashboard to visualise all of the active lone worker check-ins or extending the workflow to create a group call with the supervisor and the lone worker if the lone worker does not answer the retries.

Alex Dunne is a Software Engineer based in Birmingham, UK. Often found experimenting with new technology to embrace into an existing well-rounded skill set.