---
title: 'Adding a Page View Counter to a Grav CMS Site'
url: 'https://andywhittaker.com/articles/grav-page-view-counter'
markdown: 'https://andywhittaker.com/articles/grav-page-view-counter.md'
lang: en
date: '2026-04-10'
description: 'How to display a live page view counter on every article in a Grav CMS site, using the page-stats plugin, a small custom Twig plugin, and a child theme. Works on Grav 1.7 and 2.0. No third-party services required.'
taxonomy:
  category:
    - articles
  tag:
    - grav
    - php
    - linux
    - cms
    - sysadmin
    - sqlite
---

## [Adding a Page View Counter to a Grav CMS Site](https://andywhittaker.com/articles/grav-page-view-counter)

    10th Apr 2026   [grav](https://andywhittaker.com/tag:grav#body-wrapper) [php](https://andywhittaker.com/tag:php#body-wrapper) [linux](https://andywhittaker.com/tag:linux#body-wrapper) [cms](https://andywhittaker.com/tag:cms#body-wrapper) [sysadmin](https://andywhittaker.com/tag:sysadmin#body-wrapper) [sqlite](https://andywhittaker.com/tag:sqlite#body-wrapper)  

  2 views today · 470 views all time

# Adding a Page View Counter to a Grav CMS Site

[![Grav CMS Page View Counter](https://andywhittaker.com/user/pages/02.articles/grav-page-view-counter/page-counter.jpg?g-9cedeefe)](https://andywhittaker.com/articles/grav-page-view-counter/page-counter.jpg)

> **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:

```bash
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:

```bash
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:

```bash
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.

```bash
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:

```bash
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:

```bash
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`).

```bash
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:

```bash
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:

```bash
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:

```bash
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.

```bash
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:

```bash
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`:

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

If any appear, rename them:

```bash
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](https://andywhittaker.com/articles/what-is-apparmor) [Next Post ](https://andywhittaker.com/articles/sata-3v3-power-disable-enterprise-drives)

---

## Navigation

- Parent: [Articles](https://andywhittaker.com/articles.md)
- Previous: [Why Your Shiny New Enterprise SATA Drive Refuses to Power Up](https://andywhittaker.com/articles/sata-3v3-power-disable-enterprise-drives.md)
- Next: [What is AppArmor and Why Should You Care?](https://andywhittaker.com/articles/what-is-apparmor.md)
