2 views today  ·  2 views all time

Adding a Page View Counter to a Grav CMS Site

Grav CMS Page View Counter

If you run a Grav CMS site and want to show visitors how many times an article has been read โ€” today and all time โ€” this is how to do it entirely self-hosted, with no JavaScript trackers or third-party analytics services involved.

The end result looks like this, sitting neatly below the article title and tags:

๐Ÿ‘ 3 views today ยท 403 views all time

How It Works

The solution has three components:

  1. The page-stats plugin โ€” already available for Grav, this records every page hit into a local SQLite database on your server.
  2. A custom page-counter plugin โ€” a small PHP plugin we write ourselves, which exposes a Twig function that queries the SQLite database.
  3. A template edit โ€” a one-block addition to blog-item.html.twig that calls the function and renders the counts.

Twig (Grav's templating language) cannot query a database directly, which is why the custom plugin is needed as a bridge.

Prerequisites

  • Grav CMS running on Ubuntu with Apache
  • The page-stats plugin installed and working
  • PHP 8.x with the php8.x-sqlite3 extension installed

If you do not have the page-stats plugin working yet, install it via the Grav admin panel, then run:

sudo apt install php8.3-sqlite3
sudo systemctl restart apache2
sudo chown -R www-data:www-data /var/www/grav/user/plugins/page-stats
sudo rm -rf /var/www/grav/cache/*

Verify the plugin is recording hits by checking the database exists and has data:

sudo apt install sqlite3
sudo sqlite3 /var/www/grav/user/data/page-data.sqlite "SELECT DISTINCT route FROM data WHERE route LIKE '/articles/%' LIMIT 10;"

If you see article routes in the output, the plugin is working correctly.

Step 1 โ€” Create the page-counter Plugin

Create the plugin directory:

sudo mkdir /var/www/grav/user/plugins/page-counter

Create the main plugin file:

sudo nano /var/www/grav/user/plugins/page-counter/page-counter.php

Paste the following:

<?php
/**
 * PageCounter Plugin
 *
 * Exposes a Twig function page_view_stats(route) that queries the
 * page-stats plugin SQLite database and returns today's and all-time
 * view counts for a given page route, excluding bot traffic.
 */
namespace Grav\Plugin;

use Grav\Common\Plugin;
use PDO;

class PageCounterPlugin extends Plugin
{
    /**
     * Returns the list of events this plugin subscribes to.
     */
    public static function getSubscribedEvents()
    {
        return [
            'onTwigExtensions' => ['onTwigExtensions', 0],
        ];
    }

    /**
     * Registers the page_view_stats() Twig function.
     */
    public function onTwigExtensions()
    {
        $this->grav['twig']->twig->addFunction(
            new \Twig\TwigFunction('page_view_stats', [$this, 'getPageViewStats'])
        );
    }

    /**
     * Queries the page-stats SQLite database for view counts on a given route.
     * Returns an array with 'today' and 'total' integer counts.
     * Returns zeros silently on any error so the page still renders.
     *
     * @param string $route  The page route, e.g. /articles/what-is-apparmor
     * @return array         ['today' => int, 'total' => int]
     */
    public function getPageViewStats($route)
    {
        $db_path = GRAV_ROOT . '/user/data/page-data.sqlite';

        if (!file_exists($db_path))
        {
            return ['today' => 0, 'total' => 0];
        }

        try
        {
            $db = new PDO('sqlite:' . $db_path);
            $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

            // All-time views excluding bots
            $stmt = $db->prepare(
                "SELECT COUNT(*) FROM data WHERE route = :route AND is_bot = 0"
            );
            $stmt->execute([':route' => $route]);
            $total = (int) $stmt->fetchColumn();

            // Today's views excluding bots
            $stmt = $db->prepare(
                "SELECT COUNT(*) FROM data
                 WHERE route = :route
                 AND is_bot = 0
                 AND date(date) = date('now', 'localtime')"
            );
            $stmt->execute([':route' => $route]);
            $today = (int) $stmt->fetchColumn();

            return ['today' => $today, 'total' => $total];
        }
        catch (\Exception $e)
        {
            return ['today' => 0, 'total' => 0];
        }
    }
}

Now create the plugin config file:

sudo nano /var/www/grav/user/plugins/page-counter/page-counter.yaml
enabled: true

Fix ownership:

sudo chown -R www-data:www-data /var/www/grav/user/plugins/page-counter

Step 2 โ€” Edit the Article Template

The Quark theme renders articles using blog-item.html.twig. We need to add the counter block to this template.

Important: Do not add the counter inside the {% if not hero_image_name %} block โ€” if you do, articles without a hero image header will show the counter but articles with one will not. Place it outside that block so it always renders.

Edit the template:

sudo nano /var/www/grav/user/themes/quark/templates/partials/blog-item.html.twig

Find this section:

    </div>
{% endif %}
    <div class="e-content">

And insert the counter block between those two lines:

    </div>
{% endif %}

{# Page view counter โ€” outside hero_image conditional so it renders on all articles #}
{% set stats = page_view_stats(page.route) %}
<p class="page-stats text-center" style="font-size: 0.85em; color: #888; margin-top: 0.5em;">
    <i class="fa fa-eye"></i>&nbsp;
    {{ stats.today }} view{% if stats.today != 1 %}s{% endif %} today
    &nbsp;&middot;&nbsp;
    {{ stats.total }} view{% if stats.total != 1 %}s{% endif %} all time
</p>

    <div class="e-content">

Fix ownership and clear the cache:

sudo chown www-data:www-data /var/www/grav/user/themes/quark/templates/partials/blog-item.html.twig
sudo rm -rf /var/www/grav/cache/*

Step 3 โ€” Verify Article Template Names

Grav chooses which template to use based on the filename of the page content file. A file named item.md uses item.html.twig. A file named default.md uses default.html.twig instead โ€” and will not pick up any of the changes above.

Check whether any of your articles are using default.md:

sudo find /var/www/grav/user/pages/02.articles -name "default.md"

If any appear, rename them:

sudo mv /var/www/grav/user/pages/02.articles/your-article/default.md \
        /var/www/grav/user/pages/02.articles/your-article/item.md
sudo rm -rf /var/www/grav/cache/*

The Result

Every article on the site now shows a live view counter directly below the title and tags, excluding bot traffic, powered entirely by data already being collected by the page-stats plugin. No external services, no JavaScript trackers, no additional database infrastructure.

The page-counter plugin fails silently โ€” if the database is missing or a query fails for any reason, it returns zero counts and the page continues to render normally.

Previous Post