Building an online presence counter with Symfony

Introduction

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 tutorial, 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:

symfony-online-presence-demo

Prerequisites

  • PHP 7.1 or newer.
  • Composer.
  • A Pusher account. Create one here.

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:

1# config/routes.yaml
2    
3    index:
4        path: /home
5        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:

1// src/Controller/HomeController.php
2    
3    <?php
4    
5    namespace App\Controller;
6    
7    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
8    
9    class HomeController extends AbstractController
10    {
11        public function index()
12        {
13            $visitorCount = $this->getVisitorCount();
14            return $this->render('index.html.twig', [
15                'visitorCount' => $visitorCount,
16            ]);
17        }
18    }

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:

1{# templates/index.html.twig #}
2    
3    {% extends 'base.html.twig' %}
4    
5    {% block body %}
6    <style>
7        body {
8            font-family: "Lucida Console", monospace, sans-serif;
9            padding: 30px;
10        }
11    </style>
12        <h2 align="center" id="visitorCount">{{ visitorCount }}</h2>
13        <p align="center">person(s) currently viewing this page</p>
14    {% 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 a channel_vacated notification.
  • When the backend receives the channel_occupied or channel_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:

1{# templates/index.html.twig #}
2    
3    {% block javascripts %}
4        <script src="https://js.pusher.com/4.2/pusher.min.js"></script>
5        <script>
6    
7        let pusher = new Pusher("{{ pusherKey }}", {
8            cluster: "{{ pusherCluster }}",
9        });
10        let channelName = Date.now() + Math.random().toString(36).replace(/\W+/g, '');
11        pusher.subscribe(channelName);
12        pusher.subscribe("visitor-updates")
13            .bind('update', function (data) {
14                console.log(data)
15                let newCount = data.newCount;
16                document.getElementById('visitorCount').textContent = newCount;
17            });
18        </script>
19    {% 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:

1###> pusher/pusher-php-server ###
2    PUSHER_APP_ID=
3    PUSHER_KEY=
4    PUSHER_SECRET=
5    ###< 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:

1###> pusher/pusher-php-server ###
2    PUSHER_APP_ID=your-app-id
3    PUSHER_KEY=your-app-key
4    PUSHER_SECRET=your-app-secret
5    PUSHER_CLUSTER=your-app-cluster
6    ###< 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:

1# config/packages/pusher_php_server.yaml
2    
3    services:
4        Pusher\Pusher:
5            public: true
6            arguments:
7                - '%env(PUSHER_KEY)%'
8                - '%env(PUSHER_SECRET)%'
9                - '%env(PUSHER_APP_ID)%'
10                - { cluster: '%env(PUSHER_CLUSTER)%' }
11                
12    lopi_pusher:
13        key: '%env(PUSHER_KEY)%'
14        secret: '%env(PUSHER_SECRET)%'
15        app_id: '%env(PUSHER_APP_ID)%'
16        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:

1$ config/services.yaml
2    
3    parameters:
4        locale: 'en'
5        pusherKey: '%env(PUSHER_KEY)%'
6        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:

1// src/Controller/HomeController.php
2    
3        public function index()
4        {
5            $visitorCount = $this->getVisitorCount();
6            return $this->render('index.html.twig', [
7                'pusherKey' => $this->getParameter('pusherKey'),
8                'pusherCluster' => $this->getParameter('pusherCluster'),
9                'visitorCount' => $visitorCount,
10            ]);
11        }

Broadcasting changes

We’ll create a new route to handle webhook calls from Pusher. Add a new entry to your config/routes.yaml):

1# config/routes.yaml
2    
3    webhook:
4        path: /webhook
5        methods:
6        - post
7        controller: App\Controller\HomeController::webhook

Then create the corresponding method in your controller:

1// src/Controller/HomeController.php
2     
3    public function webhook(Request $request, Pusher $pusher)
4    {
5        $events = json_decode($request->getContent(), true)['events'];
6        $visitorCount = $this->getVisitorCount();
7        foreach ($events as $event) {
8            // ignore any events from our public channel--it's only for broadcasting
9            if ($event['channel'] === 'visitor-updates') {
10                continue;
11            }
12            $visitorCount += ($event['name'] === 'channel_occupied') ? 1 : -1;
13        }
14            // save new figure and notify all clients
15            $this->saveVisitorCount($visitorCount);
16            $pusher->trigger('visitor-updates', 'update', [
17                'newCount' => $visitorCount,
18            ]);
19        return new Response();
20    }

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:

1// src/Controller/HomeController.php
2    
3    <?php
4    
5    namespace App\Controller;
6    
7    use Psr\SimpleCache\CacheInterface;
8    use Pusher\Pusher;
9    use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
10    use Symfony\Component\HttpFoundation\Request;
11    use Symfony\Component\HttpFoundation\Response;
12    
13    class HomeController extends AbstractController
14    {
15        public function __construct(CacheInterface $cache)
16        {
17            $this->cache = $cache;
18        }
19    
20        public function index()
21        {
22            $visitorCount = $this->getVisitorCount();
23            return $this->render('index.html.twig', [
24                'pusherKey' => $this->getParameter('pusherKey'),
25                'pusherCluster' => $this->getParameter('pusherCluster'),
26                'visitorCount' => $visitorCount,
27            ]);
28        }
29    
30        public function webhook(Request $request, Pusher $pusher)
31        {
32            $events = json_decode($request->getContent(), true)['events'];
33            $visitorCount = $this->getVisitorCount();
34            foreach ($events as $event) {
35                // ignore any events from our public channel--it's only for broadcasting
36                if ($event['channel'] === 'visitor-updates') {
37                    continue;
38                }
39                $visitorCount += ($event['name'] === 'channel_occupied') ? 1 : -1;
40            }
41                // save new figure and notify all clients
42                $this->saveVisitorCount($visitorCount);
43                $pusher->trigger('visitor-updates', 'update', [
44                    'newCount' => $visitorCount,
45                ]);
46            return new Response();
47        }
48    
49        private function getVisitorCount()
50        {
51            return $this->cache->get('visitorCount') ?: 0;
52        }
53    
54        private function saveVisitorCount($visitorCount)
55        {
56            $this->cache->set('visitorCount', $visitorCount);
57        }
58    
59    }

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:

symfony-online-presence-ngrok

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 of prefix_seed (under the cache 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 tutorial, 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.