justinholtweb / craft-tally
Counts what people actually read. View counts on every element, popular and trending queries in Twig, and a report in the control panel — cache-safe, cookie-free and free.
Package info
github.com/justinholtweb/craft-tally
Type:craft-plugin
pkg:composer/justinholtweb/craft-tally
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- craftcms/cms: ^5.3.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 16:12:28 UTC
README
Counts what people actually read.
A view counter is a small idea with one hard part, and it is not the counting. It is that the moment a site gets fast enough to need a page cache, the obvious implementation stops working — Craft renders the page once, the cache serves it to everybody else, and the number quietly becomes "how many cache misses happened".
Tally counts either way. Server-side while Craft renders, or through a signed beacon the browser
fetches, which always reaches Craft because nothing sensible caches a 43-byte image with
Cache-Control: no-store on it.
Then it gives you the numbers where you want them: entry.tally.total in a template,
craft.tally.popular() as an ordinary chainable element query, a sortable Views column on entry
indexes, a panel in the entry sidebar, a dashboard widget, and a report in the control panel.
Free. Craft 5.3+, PHP 8.2+. No outbound requests, no third-party services, no build step, no cookies, and nothing about a visitor is ever written to the database.
Reference point: the abandoned Views Work plugin, whose feature list this covers — pageviews by day, week, month and lifetime, a trending list, a control-panel widget and a signed tracking image — plus everything that turned out to be missing once it was gone.
Install
composer require justinholtweb/craft-tally php craft plugin/install tally
That is the whole setup. Every front-end page that resolves to an element is counted from then on.
What it does
Counts a view
Three ways, and the right one depends on what is in front of the site.
Automatically (the default). Craft renders a page, the page resolves to an element, the view is counted. Two statements, no reads, nothing on the response.
With a beacon, for a site behind Blitz, Varnish, a CDN or any other full-page cache. The page carries a signed token, the browser fetches one tiny URL, and Craft counts it there. The token names exactly one element, one site and one counter, and is signed with the site's security key — without that the endpoint would be "increment any number you like by id".
<!-- what gets added to the page, in beacon mode --> <img src="/actions/tally/track/beacon?tallyToken=Mjg3Yjc2…" alt="" width="1" height="1" aria-hidden="true" decoding="async" style="position:absolute;width:1px;height:1px;opacity:0">
The image counts readers with JavaScript switched off. The script alternative does not count a page
the browser merely prefetched, and carries its URL in a data attribute so it needs no
unsafe-inline in a content security policy.
Only when asked, from a template or from PHP:
{% do craft.tally.record(entry) %}
Decides whether it was a person
In order, cheapest first:
- not the control panel, not a preview, not a console request
DNT: 1orSec-GPC: 1, if honouring those is switched on- a user agent on the robot list
- an address on the ignore list
- a signed-in user, if the settings say to skip those
- the same visitor reading the same page again inside the dedupe window
The order matters more than it looks. Asking Craft who the visitor is opens a session, a session
means a Set-Cookie on the response, and a Set-Cookie on every response is a full-page cache that
never hits again. So Tally only asks once it can already see a session cookie on the request. An
anonymous reader never pays for that setting.
The repeat-visit test hashes the address and user agent with the site's security key, keeps the hash in the cache for exactly as long as the dedupe window, and never writes it anywhere. There is no visitor table, no cookie, and no way to get an address back out of what is stored.
Stores two things
tally_totals — one row per element per site per counter, holding the lifetime number.
tally_daily — one row per element per day, which is what makes "popular this week" answerable.
There is deliberately no row per view. A hit log is the obvious third table and it is a trap: it
grows without bound on exactly the sites that need this plugin most, it stores a visitor's address
to answer a question nobody asked, and every query written against it is a GROUP BY over millions
of rows that a daily bucket already answered.
Retention prunes day buckets and never touches lifetime totals. Losing a lifetime count to a settings change is not something an editor should ever have happen to them.
Twig
One element's numbers
Every element grows a tally property. Nothing is read until something asks.
{{ entry.tally.total }} {# all time #}
{{ entry.tally.today }}
{{ entry.tally.week }} {# last 7 days #}
{{ entry.tally.month }} {# last 30 days #}
{{ entry.tally.year }} {# last 12 months #}
{{ entry.tally.thisWeek }} {# the calendar week so far #}
{{ entry.tally.thisMonth }}
{{ entry.tally.thisYear }}
{{ entry.tally.period(90) }} {# any window #}
{{ entry.tally.lastViewed|datetime }}
{{ entry.tally.trend('week') }} {# % against the previous 7 days, or null #}
Or through the variable, which also takes a bare id:
{{ craft.tally.views(entry) }}
{{ craft.tally.views(entry, 'week') }}
{{ craft.tally.views(1234, 'month') }}
A period may be 'today', 'yesterday', 'week', 'month', 'year', 'thisWeek',
'thisMonth', 'thisYear', 'all', a number of days (30), or a phrase like 'last90days'.
Days are calendar days in the site's own time zone, because "today" is a question about where
the person asking is standing.
Popular and trending
Both return an ordinary element query. Chain anything you like onto it — .with(), .search(),
.count(), pagination — and every rule Craft has about drafts, revisions, statuses and enabled
sites still applies.
{% for entry in craft.tally.popular({ section: 'articles', period: 'month', limit: 5 }).all() %}
<a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.tally.month }} views
{% endfor %}
{% for entry in craft.tally.trending({ limit: 5 }).all() %}
Trending is not "most viewed recently". It is most viewed recently weighted by how recently, so a piece that took two hundred views yesterday outranks one that took three hundred spread over the week — which is what somebody means when they say something is taking off.
To rank a query you built yourself:
{% set query = craft.entries.section('articles').type('review') %}
{% for entry in craft.tally.sort(query, { period: 'week' }).all() %}
Options, all optional:
| Option | Default | Meaning |
|---|---|---|
section |
every section | A section handle, or a list of them |
elementType |
craft\elements\Entry |
Rank something other than entries |
period |
all time | Any window craft.tally.period() accepts |
limit |
none | How many |
siteId |
the current site | An id, or '*' for every site at once |
order |
'desc' |
'asc' for the least-read |
minViews |
none | Ignore anything under this |
includeUnviewed |
false |
Keep elements with no views, at the bottom |
key |
the default counter | Which counter to rank by |
Charts
{% for date, views in craft.tally.series(entry, 30) %}
<div style="height: {{ views }}px" title="{{ date }}"></div>
{% endfor %}
The quiet days 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.tally.siteSeries(30) does the same for the whole site.
Beacons, by hand
Turn auto-injection off and place it yourself, wherever it belongs:
{{ craft.tally.beacon(entry) }} {# the whole tag #}
{{ craft.tally.beaconUrl(entry) }} {# just the URL, for your own JS #}
Everything else
{{ craft.tally.top('week', { limit: 10 }) }} {# element ids and counts, no elements loaded #}
{{ craft.tally.summary().views }} {# site-wide figures #}
{{ craft.tally.period('week').label }} {# "Last 7 days" #}
{{ craft.tally.isCounting }}
More than one counter
Every method takes a key, so one element can carry several independent counters — reads, plays,
downloads, shares — without any of them knowing about the others.
{% do craft.tally.record(entry, { key: 'downloads' }) %}
{{ craft.tally.views(entry, 'all', { key: 'downloads' }) }}
In the control panel
- Tally → Popular — the board, for any window and any site, with a sparkline per row, a drill-down per element and a CSV export.
- A Views column on entry indexes, sortable, wherever elements can have URLs. One join for the whole page, not one query per row.
- A panel in the entry sidebar with the lifetime number, the last seven days, and thirty days of history. No field layout to edit.
- A Popular content widget for the dashboard, with a trending mode.
- A Tally Views field, for editors who would rather have the number inside a tab. It stores
nothing, so adding it costs one row in
fieldsand removing it can never lose data.
Two permissions: View reports, and Reset and prune view counts nested under it.
PHP
use justinholtweb\tally\Plugin; use justinholtweb\tally\models\Period; $tally = Plugin::getInstance(); $tally->views->record($entry); // subject to every rule $tally->views->record($entry, ['force' => true]); // regardless of them $tally->views->total($entry->id, $siteId); $tally->views->count($entry->id, $siteId, null, Period::lastDays(7)); $tally->views->totals([1, 2, 3], $siteId); // batched $tally->views->series($entry->id, $siteId, null, Period::lastDays(30)); $tally->ranking->popular(['section' => 'articles', 'period' => 'week']); $tally->ranking->apply($someElementQuery, ['period' => 'week']);
Two events, both on the views service:
use justinholtweb\tally\events\ViewEvent; use justinholtweb\tally\services\Views; Event::on(Views::class, Views::EVENT_BEFORE_RECORD, function(ViewEvent $event) { $event->isValid = false; // and this view is not counted }); Event::on(Views::class, Views::EVENT_AFTER_RECORD, function(ViewEvent $event) { // $event->elementId, ->siteId, ->key, ->element, ->isProgrammatic });
Console
php craft tally/views/top --period=week --limit=20 php craft tally/views/summary php craft tally/views/prune # retention; Craft's own garbage collection does this too php craft tally/views/recount # rebuild totals from day buckets php craft tally/views/reset --elementId=1234
--allSites, --siteId, --key and --period are available where they make sense.
Settings
Everything below has a working default; a fresh install needs none of it.
| Setting | Default | |
|---|---|---|
| How views are counted | automatically | Or beacon, manual, off |
| Beacon type | an image | Or a script |
| Token lifetime | never expires | See below |
| Repeat views | 30 minutes | 0 counts every request |
| Sections | every one | Or only / except a list |
| Other element types | counted | Categories, products, anything with a URL |
| Ignore robots | on | With a list of 60-odd user agents |
| Ignored addresses | none | Addresses, prefixes, CIDR ranges |
| Signed-in users | ignore admins | Or everybody, or nobody |
| Honour Do Not Track | on | |
| Keep history for | 730 days | 0 keeps it forever |
| Trending window | 7 days | |
| Trending half-life | 3 days | 0 turns decay off |
Token lifetime defaults to "never expires" on purpose. In beacon mode the token lives inside the page's HTML, and on a cached site that HTML may be served for weeks. An expiring token there is a counter that quietly stops. The token permits exactly one thing — adding to one element's count — and every other rule still applies to the request that carries it.
What Tally is not
It is not analytics. There are no sessions, no referrers, no bounce rates, no funnels and no per-visitor anything, because all of that requires keeping records about people and this does not. If you want analytics, use analytics — Telescope puts GA4 in the control panel and is also free.
What Tally answers is "which of these has been read, and how much", which is the question that actually changes what a site puts on its homepage.
Documentation
Licence
The Craft License. See LICENSE.md. Tally is free: no editions, no licence key, and no licensing
code in the plugin.