2 views today · 2 views all time
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
The solution has three components:
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.
php8.x-sqlite3 extension installedIf 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.
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
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>
{{ stats.today }} view{% if stats.today != 1 %}s{% endif %} today
·
{{ 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/*
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/*
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.