How to Build an Automated Lone Worker System in PHP using Symfony Workflow & Twilio
Time to read: 17 minutes
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:
- Basic knowledge of Symfony
- Symfony and Composer installed locally
- Redis setup on your machine
- A free Twilio account
- Ngrok installed and authenticated
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:
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:
When prompted, run the library recipes to allow them to create the necessary files. You may see the following error after running the recipes:
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:
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:
Inside this file add a REDIS_URL
key and set the value to point to your Redis instance:
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:
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.
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:
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:
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:
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:
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:
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:
Additionally, create a file named show.html.twig
and insert the following code:
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:
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
- An initial call is placed to the lone worker to confirm they want the check-ins
- 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
- The queued message has been processed and requires a check-in with the lone worker
- 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
- The retry message has been processed and we need to retry checking in with the loner worker
- 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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
.
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:
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:
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:
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:
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:
You’ll now need to implement the startCheckIn
function. Head over to the AutomatedCheckInService
and implement the method like so:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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.
- Twitter: @i_dunne_that
- Website: http://alexdunne.net/
- GitHub: https://github.com/alexdunne
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.