Building an online presence counter with Symfony
You will need PHP 7.1 and Composer installed on your machine.
Symfony is a popular PHP framework. It’s built in a component form that allows users to pick and choose the components they need. In this article, we’ll build a Symfony app that uses Pusher Channels to display the current number of visitors to a particular page in realtime. Here’s a preview of our app in action:
Prerequisites
Setting up
Create a new Symfony project called “countess” by running the following command:
composer create-project symfony/website-skeleton countess
We’re ready to start building. Let’s create the route for the lone page in our app. Open up the file config/routes.yaml
and replace its contents with the following:
# config/routes.yaml
index:
path: /home
controller: App\Controller\HomeController::index
Note: We’re going to be working with YAML files quite a bit in this article. In YAML, indentation matters, so be careful to stick to what is shown!
Next, we’ll create the controller. Create the file src/Controller/HomeController.php
with the following contents:
// src/Controller/HomeController.php
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class HomeController extends AbstractController
{
public function index()
{
$visitorCount = $this->getVisitorCount();
return $this->render('index.html.twig', [
'visitorCount' => $visitorCount,
]);
}
}
You’ll notice we’re calling the non-existent method getVisitorCount()
to get the current visitor count before rendering the page. We’ll come back to that in a bit.
Let’s create the view that shows the visitor count. Create the file templates/index.html.twig
with the following content:
{# templates/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<style>
body {
font-family: "Lucida Console", monospace, sans-serif;
padding: 30px;
}
</style>
<h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
<p align="center">person(s) currently viewing this page</p>
{% endblock %}
Now let’s make the visitor count live. We have two tasks to achieve here:
- Retrieve the number of people viewing the page
- Update this number when someone loads the page or leaves it
Here’s how we’ll do this:
- Whenever the page is rendered on a browser, it subscribes to two public Pusher channels:
- An existing channel (let’s call this
visitor-updates
). This is the channel where it will receive updates on the number of visitors. - A new channel with a randomly generated name. The purpose of this channel is to trigger a Pusher event called
channel_occupied
, which will be sent via a webhook to our backend. Also, when the user leaves the page, the Pusher connection will be terminated, resulting in achannel_vacated
notification. - When the backend receives the
channel_occupied
orchannel_vacated
notifications, it re-calculates the visitor count and does two things: - It broadcasts the new value on the
visitor-updates
channel. Pages subscribed to this channel can then update their UI to reflect the new value. - It records this new value in a cache so that when rendering a new page, it can retrieve the number from the cache (in the
getVisitorCount
method).
Okay, let’s do this!
First, we’ll write the frontend code that implements item (2). Add the following to the bottom of your view:
{# templates/index.html.twig #}
{% block javascripts %}
<script src="https://js.pusher.com/4.2/pusher.min.js"></script>
<script>
let pusher = new Pusher("{{ pusherKey }}", {
cluster: "{{ pusherCluster }}",
});
let channelName = Date.now() + Math.random().toString(36).replace(/\W+/g, '');
pusher.subscribe(channelName);
pusher.subscribe("visitor-updates")
.bind('update', function (data) {
console.log(data)
let newCount = data.newCount;
document.getElementById('visitorCount').textContent = newCount;
});
</script>
{% endblock %}
We’re referencing a few variables here in the view (pusherKey
, pusherCluster
) which we haven’t defined in the controller. We’ll get to that in a moment. First, let’s configure Pusher on our backend.
Configuring Pusher
Run the following command to install the Pusher bundle for Symfony:
composer require laupifrpar/pusher-bundle
Note: When installing this, Symfony Flex will ask you if you want to execute the recipe. Choose ‘yes’. You can read more about Symfony Flex here.
You’ll notice some new lines have been added to your .env
file:
###> pusher/pusher-php-server ###
PUSHER_APP_ID=
PUSHER_KEY=
PUSHER_SECRET=
###< pusher/pusher-php-server ###
Add an extra line to these:
PUSHER_CLUSTER=
Then provide all the PUSHER_*
variables with your credentials from your Pusher app dashboard:
###> pusher/pusher-php-server ###
PUSHER_APP_ID=your-app-id
PUSHER_KEY=your-app-key
PUSHER_SECRET=your-app-secret
PUSHER_CLUSTER=your-app-cluster
###< pusher/pusher-php-server ###
After installing the Pusher bundle, you should have a file called pusher_php_server.yaml
in the config/packages
directory. Replace its contents with the following:
# config/packages/pusher_php_server.yaml
services:
Pusher\Pusher:
public: true
arguments:
- '%env(PUSHER_KEY)%'
- '%env(PUSHER_SECRET)%'
- '%env(PUSHER_APP_ID)%'
- { cluster: '%env(PUSHER_CLUSTER)%' }
lopi_pusher:
key: '%env(PUSHER_KEY)%'
secret: '%env(PUSHER_SECRET)%'
app_id: '%env(PUSHER_APP_ID)%'
cluster: '%env(PUSHER_CLUSTER)%'
Now, let’s add the Pusher credentials for our frontend. Open up the file config/services.yaml
and replace the parameters
section near the top with this:
$ config/services.yaml
parameters:
locale: 'en'
pusherKey: '%env(PUSHER_KEY)%'
pusherCluster: '%env(PUSHER_CLUSTER)%'
Here, we’re using parameters in our service container to reference the needed credentials, so we can easily access them from anywhere in our app. Now update the HomeController
‘s index
method so it looks like this:
// src/Controller/HomeController.php
public function index()
{
$visitorCount = $this->getVisitorCount();
return $this->render('index.html.twig', [
'pusherKey' => $this->getParameter('pusherKey'),
'pusherCluster' => $this->getParameter('pusherCluster'),
'visitorCount' => $visitorCount,
]);
}
Broadcasting changes
We’ll create a new route to handle webhook calls from Pusher. Add a new entry to your config/routes.yaml
):
# config/routes.yaml
webhook:
path: /webhook
methods:
- post
controller: App\Controller\HomeController::webhook
Then create the corresponding method in your controller:
// src/Controller/HomeController.php
public function webhook(Request $request, Pusher $pusher)
{
$events = json_decode($request->getContent(), true)['events'];
$visitorCount = $this->getVisitorCount();
foreach ($events as $event) {
// ignore any events from our public channel--it's only for broadcasting
if ($event['channel'] === 'visitor-updates') {
continue;
}
$visitorCount += ($event['name'] === 'channel_occupied') ? 1 : -1;
}
// save new figure and notify all clients
$this->saveVisitorCount($visitorCount);
$pusher->trigger('visitor-updates', 'update', [
'newCount' => $visitorCount,
]);
return new Response();
}
The saveVisitorCount
method is where we store the new visitor count in the cache. We’ll implement that now.
Using a cache
We’re using a cache to store the current visitor count so we can track it across sessions. To keep this demo simple, we’ll use a file on our machine as our cache. Let’s do this.
Fortunately, since we’re using the Symfony framework bundle, the filesystem cache is already set up for us. We only need to add it in as a parameter to our controller’s constructor. Let’s update our controller and add the getVisitorCount
and updateVisitorCount
methods to make use of the cache:
// src/Controller/HomeController.php
<?php
namespace App\Controller;
use Psr\SimpleCache\CacheInterface;
use Pusher\Pusher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class HomeController extends AbstractController
{
public function __construct(CacheInterface $cache)
{
$this->cache = $cache;
}
public function index()
{
$visitorCount = $this->getVisitorCount();
return $this->render('index.html.twig', [
'pusherKey' => $this->getParameter('pusherKey'),
'pusherCluster' => $this->getParameter('pusherCluster'),
'visitorCount' => $visitorCount,
]);
}
public function webhook(Request $request, Pusher $pusher)
{
$events = json_decode($request->getContent(), true)['events'];
$visitorCount = $this->getVisitorCount();
foreach ($events as $event) {
// ignore any events from our public channel--it's only for broadcasting
if ($event['channel'] === 'visitor-updates') {
continue;
}
$visitorCount += ($event['name'] === 'channel_occupied') ? 1 : -1;
}
// save new figure and notify all clients
$this->saveVisitorCount($visitorCount);
$pusher->trigger('visitor-updates', 'update', [
'newCount' => $visitorCount,
]);
return new Response();
}
private function getVisitorCount()
{
return $this->cache->get('visitorCount') ?: 0;
}
private function saveVisitorCount($visitorCount)
{
$this->cache->set('visitorCount', $visitorCount);
}
}
Publishing the webhook
We need to do a few things before our webhook is ready for use.
Since the application currently lives on our local machine, we need a way of exposing it via a public URL. Ngrok is an easy-to-use tool that helps with this. If you don’t already have it installed, sign up on http://ngrok.com and follow the instructions to install ngrok. Then expose http://localhost:8000 on your machine by running:
./ngrok http 8000
You should see output like this:
Copy the second Forwarding URL (the one using HTTPS). Your webhook URL will then be <your-ngrok-url>/webhook
(for instance, for the screenshot above, my webhook URL is https://fa74c4e1.ngrok.io/webhook
).
Next, you’ll need to enable channel existence webhooks for our Pusher app. On your Pusher app dashboard, click on the Webhooks tab and select the Channel existence radio button. In the text box, paste the URL of the webhook you obtained above, and click Add. Good to go!
Start the app by running:
php bin/console server:run
Now visit http://localhost:8000/home in a browser. Open it in multiple tabs and you should see the number of visitors go up or down as you open and close tabs.
Tip: If you made a mistake earlier in this tutorial, you might find that the page updates in a weird manner. This is because the cache is in an inconsistent state. To fix this, you’ll need to clear the cache. An easy way to fix this is by opening up the
config/packages/framework.yaml
file and changing the value ofprefix_seed
(under thecache
key) to some random value:
prefix_seed: hahalol
This has the same effect as telling the app to use a new cache folder.
Conclusion
In this article, we’ve built a simple demo showing how we can add realtime capabilities to a Symfony app. We could go on to display the number of actual users by filtering by factors such as their IP address. If our app involved signing in, we could even use presence channels to know who exactly was viewing the page. I hope you enjoyed this tutorial. You can check out the source code of the completed application on GitHub.
12 September 2018
by Shalvah Adebayo