Odometer for Craft CMS

Usage

In templates

One element's numbers

Every element grows a odometer property. Nothing is read until something asks for it, so a listing that renders a hundred of them and reads none costs nothing.

{{ entry.odometer.total }}          {# all time #}
{{ entry.odometer.today }}
{{ entry.odometer.yesterday }}
{{ entry.odometer.week }}           {# the last 7 days, today included #}
{{ entry.odometer.month }}          {# the last 30 days #}
{{ entry.odometer.year }}           {# the last 12 months #}
{{ entry.odometer.thisWeek }}       {# the calendar week so far #}
{{ entry.odometer.thisMonth }}
{{ entry.odometer.thisYear }}
{{ entry.odometer.period(90) }}     {# any window #}
{{ entry.odometer.lastViewed }}     {# a DateTime, or null #}
{{ entry.odometer.trend('week') }}  {# % against the previous 7 days, or null #}

{{ entry.odometer }} on its own prints the lifetime total.

The same through the variable, which also accepts a bare element id:

{{ craft.odometer.views(entry) }}
{{ craft.odometer.views(entry, 'week') }}
{{ craft.odometer.views(1234, 'month', { siteId: 2 }) }}
{% set stats = craft.odometer.stats(entry) %}

Periods

Anywhere a period is accepted:

'today', 'yesterday'
'week', 'month', 'year'rolling: the last 7, 30 or 365 days
'thisWeek', 'thisMonth', 'thisYear'calendar: since Monday, the 1st, or January
30, '30', 'last90days'that many days, today included
'all', 'total', nothing at allall time

Days are calendar days in the site's own time zone, because "today" is a question about where the person asking is standing.

Anything unrecognised resolves to all time rather than throwing. A typo in a template should not be a 500 on somebody's homepage.

Most read

{% for entry in craft.odometer.popular({ section: 'articles', period: 'month', limit: 5 }).all() %}
    <a href="{{ entry.url }}">{{ entry.title }}</a>
    <span>{{ entry.odometer.month }} views</span>
{% endfor %}

It returns an ordinary element query, so everything else still works:

{% set query = craft.odometer.popular({ section: 'articles', period: 'week' }) %}
{% paginate query.limit(10) as pageInfo, entries %}

Trending

{% for entry in craft.odometer.trending({ limit: 5 }).all() %}

Trending weights recent views more heavily, so a piece that took two hundred views yesterday outranks one that took three hundred spread over the week. The window and the decay are settings; you can override either per call:

{{ craft.odometer.trending({ period: 14, halfLife: 5, limit: 10 }) }}

Ranking a query you built yourself

{% set query = craft.entries.section('articles').type('review').relatedTo(category) %}
{% for entry in craft.odometer.sort(query, { period: 'week' }).all() %}

sort() adds the ranking to a query and leaves every filter on it alone. Calling it twice on the same query is safe.

Options

OptionDefault
sectionevery sectionA handle, or a list of them (entries only)
elementTypecraft\elements\EntryRank categories, products, anything
periodall time (trending: the window setting)
limitnone
siteIdthe current siteOr '*' for every site at once
order'desc''asc' gives you the least-read
minViewsnoneDrop anything under this
includeUnviewedfalseKeep never-read elements, at the bottom
keythe default counter
halfLifethe settingTrending only; 0 turns decay off

Charts

{% set series = craft.odometer.series(entry, 30) %}
{% set peak = max(series|values) %}

<div class="chart">
    {% for date, views in series %}
        <div style="height: {{ peak ? (views / peak * 100)|round : 0 }}%" title="{{ date }}: {{ views }}"></div>
    {% endfor %}
</div>

Days with no views are present with a zero. A chart drawn only from the days that have rows shows a dead week as a straight line at the top.

craft.odometer.siteSeries(30) gives the same shape for the whole site.

Recording by hand

{% do craft.odometer.record(entry) %}
{% do craft.odometer.record(entry, { key: 'downloads' }) %}

Every rule still applies — robots, repeat visits, signed-in users. Set the counting mode to Only where asked if this is the only place you want counting to happen.

Beacons by hand

{{ craft.odometer.beacon(entry) }}      {# the whole tag, image or script per the setting #}
{{ craft.odometer.beaconUrl(entry) }}   {# just the URL, for your own JavaScript #}

If a template places one, the automatic injection leaves the page alone — yours wins.

// with the URL, from your own code
fetch(url, { method: 'GET', credentials: 'omit', keepalive: true, cache: 'no-store' });

From PHP

use justinholtweb\odometer\Plugin;
use justinholtweb\odometer\models\Period;
use justinholtweb\odometer\services\Views;

$odometer = Plugin::getInstance();

// Writing
$odometer->views->record($entry);                                  // subject to every rule
$odometer->views->record($entry, ['force' => true]);               // regardless of them
$odometer->views->record($entry, ['key' => 'plays', 'increment' => 1]);
$odometer->views->recordId(1234, $siteId, null, force: true);
$odometer->views->set(1234, $siteId, 5000);                        // migrating in from elsewhere
$odometer->views->reset(1234, $siteId);

// Reading
$odometer->views->total(1234, $siteId);
$odometer->views->totals([1, 2, 3], $siteId);                      // one query
$odometer->views->count(1234, $siteId, null, Period::lastDays(7));
$odometer->views->counts([1, 2, 3], $siteId, null, Period::lastDays(7));
$odometer->views->series(1234, $siteId, null, Period::lastDays(30));
$odometer->views->seriesFor([1, 2], $siteId, null, Period::lastDays(30));
$odometer->views->lastViewed(1234, $siteId);
$odometer->views->top(Period::lastDays(7), ['limit' => 10]);       // ids and counts, no elements
$odometer->views->hydrate($rows, $siteId);                         // …and now the elements
$odometer->views->summary($siteId);

// Ranking
$odometer->ranking->popular(['section' => 'articles', 'period' => 'week']);
$odometer->ranking->trending(['limit' => 5]);
$odometer->ranking->apply($query, ['period' => 'month']);

// Every method takes `'*'` where a site id goes, meaning every site at once
$odometer->views->total(1234, Views::ALL_SITES);

Events

use justinholtweb\odometer\events\ViewEvent;
use justinholtweb\odometer\services\Views;
use yii\base\Event;

Event::on(Views::class, Views::EVENT_BEFORE_RECORD, function(ViewEvent $event) {
    if ($event->siteId === 3) {
        $event->isValid = false;   // this one is not counted
    }
});

Event::on(Views::class, Views::EVENT_AFTER_RECORD, function(ViewEvent $event) {
    // $event->elementId, ->siteId, ->key, ->element (when the caller had one),
    // ->isProgrammatic (true when something forced it rather than a reader causing it)
});

From the command line

php craft odometer/views/top --period=week --limit=20
php craft odometer/views/top --period=all --allSites
php craft odometer/views/summary
php craft odometer/views/prune
php craft odometer/views/recount
php craft odometer/views/reset --elementId=1234
php craft odometer/views/reset --allSites --interactive=0

--siteId, --allSites, --key and --period are available where they make sense.

recount rebuilds lifetime totals from the day buckets. On a site with retention switched on that replaces them with "views since the retention window began", so it asks first — it is a repair for a ledger that got out of step, not routine maintenance.

In the control panel

Odometer → Popular ranks everything that has been read, for any window and any site, with thirty days of history per row. Click a row for its full history, or export the whole thing as CSV.

A Views column is on the column menu of every element index where elements can have URLs, and sorts. Selected once for the whole page rather than once per row.

The entry sidebar shows the lifetime number, the last seven days and a sparkline.

The dashboard widget shows the top few for a window, a section, and optionally by trending rather than raw views.

The Odometer Views field puts the same numbers inside a field layout tab, for editors who would rather have them there. It stores nothing.