1 view today  ·  469 views all time

Adding a Page View Counter to a Grav CMS Site

Grav CMS Page View Counter

Updated 11 September 2026. This article was rewritten after migrating the site to Grav 2.0. The counter block now lives in a child theme rather than being pasted into Quark directly, because a Quark update overwrites blog-item.html.twig and silently removes the counter โ€” which is exactly what happened here. The plugin also gained a blueprints.yaml (Grav 2.0 ignores plugins without one) and a Twig sandbox hook. Everything below works on both Grav 1.7 and 2.0.

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 โ€” available from GPM, 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 child theme โ€” inherits everything from Quark and overrides a single template, blog-item.html.twig, to call the function and render the counts.

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

A note on users: the Grav tree is owned by www-data, so every bin/grav and bin/gpm command below is run as that user with sudo -u www-data. Running them as root leaves root-owned files that Apache can't write to later. The tee heredocs used to create files follow the same rule.

Prerequisites

  • Grav CMS 1.7 or 2.0 running on Ubuntu with Apache
  • PHP 8.3 with the php8.3-sqlite3 extension installed
  • The page-stats plugin installed and recording

Install the extension and the plugin:

sudo apt install php8.3-sqlite3
sudo systemctl restart apache2
cd /var/www/grav
sudo -u www-data bin/gpm install page-stats

Make sure the plugin is enabled (enabled: true in user/config/plugins/page-stats.yaml, or via Admin โ†’ Plugins), then verify it is recording hits:

sudo apt install sqlite3
sudo -u www-data sqlite3 /var/www/grav/user/data/page-data.sqlite \
  "SELECT COUNT(*), MAX(date) FROM data;"

Load a page, run the query again, and the count should have gone up.

Step 1 โ€” Create the page-counter Plugin

Create the plugin directory:

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

Every plugin needs a blueprints.yaml. On Grav 1.7 it is optional; on Grav 2.0 a plugin without one is invisible to the loader and the log will say Plugin 'page-counter' enabled but not found. The compatibility block is also what the 2.0 migration wizard reads to decide whether to carry a plugin across.

sudo -u www-data tee /var/www/grav/user/plugins/page-counter/blueprints.yaml > /dev/null <<'EOF'
name: Page Counter
slug: page-counter
type: plugin
version: 1.1.0
description: Exposes page_view_stats(route) to Twig, reading the page-stats SQLite database.
author:
  name: Andy Whittaker
license: MIT

compatibility:
  grav:
    - '1.7'
    - '2.0'

dependencies:
  - { name: grav, version: '>=1.7.0' }

form:
  validation: loose
  fields:
    enabled:
      type: toggle
      label: PLUGIN_ADMIN.PLUGIN_STATUS
      highlight: 1
      default: 0
      options:
        1: PLUGIN_ADMIN.ENABLED
        0: PLUGIN_ADMIN.DISABLED
      validate:
        type: bool
EOF

Now the plugin itself:

sudo -u www-data tee /var/www/grav/user/plugins/page-counter/page-counter.php > /dev/null <<'EOF'
<?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.
 *
 * Grav 2.0: also registers the function with the Twig sandbox so it can
 * be called from page content as well as from theme templates.
 */
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],
            'onBuildTwigSandboxPolicy' => ['onBuildTwigSandboxPolicy', 0],
        ];
    }

    /**
     * Registers the page_view_stats() Twig function.
     * Grav 2.0 exposes the Twig environment via twig(); 1.7 used a public
     * property. Both exist in 2.0, so twig() is used here.
     */
    public function onTwigExtensions()
    {
        $this->grav['twig']->twig()->addFunction(
            new \Twig\TwigFunction('page_view_stats', [$this, 'getPageViewStats'])
        );
    }

    /**
     * Adds page_view_stats to the Twig sandbox allow-list so that pages
     * with Twig-in-content enabled may call it. Theme templates are not
     * sandboxed and do not need this, but it is harmless for them.
     * The event is never fired on Grav 1.7, so this is safe there too.
     *
     * @param mixed $event  Event exposing a 'functions' array
     */
    public function onBuildTwigSandboxPolicy($event)
    {
        $functions   = $event['functions'];
        $functions[] = 'page_view_stats';
        $event['functions'] = $functions;
    }

    /**
     * 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];
        }
    }
}
EOF

If you are on Grav 1.7 and twig() is not defined, change that one line to $this->grav['twig']->twig->addFunction(.

Enable the plugin and check the file parses:

sudo -u www-data tee /var/www/grav/user/config/plugins/page-counter.yaml > /dev/null <<'EOF'
enabled: true
EOF
php -l /var/www/grav/user/plugins/page-counter/page-counter.php

Step 2 โ€” Create a Child Theme

This is the part I originally got wrong. The first version of this article said to edit Quark's templates/partials/blog-item.html.twig in place. That works until Quark is updated โ€” by bin/gpm update, by the admin panel, or by a major-version migration โ€” at which point the file is replaced and the counter vanishes with no error anywhere.

The fix is a child theme: a theme directory of its own that tells Grav "look here first, then fall back to Quark". It contains only the files you override, so Quark can be updated freely.

Create the directory and the four files. The theme is called quark-andy; use whatever name you like, but keep it consistent across the directory name, the .yaml and .php filenames, and the class name (quark-andy โ†’ QuarkAndy).

cd /var/www/grav
sudo -u www-data mkdir -p user/themes/quark-andy/templates/partials

The inheritance itself โ€” Grav resolves theme:// paths through this list in order:

sudo -u www-data tee user/themes/quark-andy/quark-andy.yaml > /dev/null <<'EOF'
streams:
  schemes:
    theme:
      type: ReadOnlyStream
      prefixes:
        '':
          - user/themes/quark-andy
          - user/themes/quark
EOF

The theme class, extending Quark's so that any PHP behaviour Quark provides carries over:

sudo -u www-data tee user/themes/quark-andy/quark-andy.php > /dev/null <<'EOF'
<?php
/**
 * Quark-Andy child theme.
 *
 * Inherits everything from Quark (see quark-andy.yaml streams) and overrides
 * only templates/partials/blog-item.html.twig to add the page view counter.
 */
namespace Grav\Theme;

require_once __DIR__ . '/../quark/quark.php';

class QuarkAndy extends Quark
{
}
EOF

The blueprint, so the admin panel and GPM know what it is:

sudo -u www-data tee user/themes/quark-andy/blueprints.yaml > /dev/null <<'EOF'
name: Quark Andy
slug: quark-andy
type: theme
version: 1.0.0
description: Child theme of Quark with a page view counter under each article title.
author:
  name: Andy Whittaker
license: MIT

compatibility:
  grav:
    - '1.7'
    - '2.0'

dependencies:
  - { name: grav, version: '>=1.7.0' }
  - { name: quark, version: '>=2.0.0' }
EOF

And the overridden template. This is Quark's blog-item.html.twig with the counter block inserted between the {% endif %} that closes the hero-image conditional and <div class="e-content">.

Important: the counter must sit outside the {% if not hero_image_name %} block. Inside it, articles with a hero image would not show the counter.

sudo -u www-data tee user/themes/quark-andy/templates/partials/blog-item.html.twig > /dev/null <<'EOF'
<div class="content-item h-entry">

{% if not hero_image_name %}
    <div class="content-title text-center">
        {% include 'partials/blog/title.html.twig' with {title_level: 'h2'} %}
        {% if page.header.subtitle %}
        <h3 >{{ page.header.subtitle }}</h3>
        {% endif %}
        {% include 'partials/blog/date.html.twig' %}
        {% include 'partials/blog/taxonomy.html.twig' %}
    </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">
        {{ page.content|raw }}
    </div>

    {% if page.header.continue_link is same as(true) and config.plugins.comments.enabled %}
        {% include 'partials/comments.html.twig' %}
    {% endif %}
</div>

<p class="prev-next text-center">
    {% if not page.isLast %}
            <a class="btn" href="{{ page.prevSibling.url }}"><i class="fa fa-angle-left"></i> {{ 'THEME_QUARK.BLOG.ITEM.PREV_POST'|t }}</a>
    {% endif %}

    {% if not page.isFirst %}
        <a class="btn" href="{{ page.nextSibling.url }}">{{ 'THEME_QUARK.BLOG.ITEM.NEXT_POST'|t }} <i class="fa fa-angle-right"></i></a>
    {% endif %}
</p>
EOF

If your Quark version's blog-item.html.twig differs from the one above, start from your own copy (user/themes/quark/templates/partials/blog-item.html.twig) and insert only the block between the two {# โ€ฆ #} markers.

Theme settings are keyed by theme name, so copy Quark's across, then switch the active theme and clear the cache:

sudo -u www-data cp user/config/themes/quark.yaml user/config/themes/quark-andy.yaml
sudo -u www-data sed -i 's/^  theme: quark$/  theme: quark-andy/' user/config/system.yaml
sudo -u www-data bin/grav clearcache

Reload an article. It should look exactly as before, with the counter below the title. If the site looks unstyled, the inheritance isn't resolving โ€” set theme: quark back in system.yaml, clear the cache, and check logs/grav.log.

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, which in Quark includes blog-item.html.twig. A file named default.md uses default.html.twig instead โ€” and will not pick up the counter.

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 -u www-data mv /var/www/grav/user/pages/02.articles/your-article/default.md \
                    /var/www/grav/user/pages/02.articles/your-article/item.md
sudo -u www-data bin/grav clearcache

Grav 2.0 Notes

A few things learned while migrating this site from 1.x to 2.0 that are relevant to the counter:

  • Plugins without blueprints.yaml are ignored. The 2.0 migration wizard shows them as a blank, disabled card, and once you enable them anyway the log says enabled but not found. Step 1 now includes the blueprint for that reason.
  • The Twig sandbox. Grav 2.0 sandboxes Twig that appears in page content. Theme templates are trusted and unaffected, so the counter works without any extra configuration; the onBuildTwigSandboxPolicy hook in the plugin is there so page_view_stats() can also be used inside a page if you ever want to.
  • Admin โ†’ Tools โ†’ Reports will flag this article. It contains Twig in fenced code blocks, so the "Twig in Content" and security scans both list it. That is a false positive โ€” the code is meant to display as text โ€” and can be ignored.
  • Bundled vendor/ directories can break the CLI. An older copy of page-stats shipped its own Symfony Console library, which clashed with the core's and made bin/grav backup fail with Call to undefined method Helper::strlen(). A fresh bin/gpm install page-stats fixed it.

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 โ€” and, this time, no dependency on Quark's own files staying untouched.

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 Next Post