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 all | all 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
| Option | Default | |
|---|---|---|
section | every section | A handle, or a list of them (entries only) |
elementType | craft\elements\Entry | Rank categories, products, anything |
period | all time (trending: the window setting) | |
limit | none | |
siteId | the current site | Or '*' for every site at once |
order | 'desc' | 'asc' gives you the least-read |
minViews | none | Drop anything under this |
includeUnviewed | false | Keep never-read elements, at the bottom |
key | the default counter | |
halfLife | the setting | Trending 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.