A single-file visitor counter for any PHP site. Counts each visitor once per IP per UTC day, stores state in a flat JSON file, and shows one number. No database, no dependencies, no build step.
Privacy-respecting by design — only salted SHA-256 IP hashes are ever written to disk, never raw IP addresses.
Live demo: the 👁 … views line in the footer of
timbeach.com.
👁 37,042 views
Most "just count my visitors" options are heavier than the problem: a database, a third-party script that phones home, or an analytics suite you don't want. A refresh-proof hit counter is genuinely about 100 lines of PHP and one JSON file. This is that.
- One file. Copy
count.phpto your web root. Done. - Refresh-proof. The same person reloading doesn't inflate the count.
- Private. Raw IPs never touch disk; they're salted-and-hashed, and the set resets daily so nothing accumulates.
- Concurrency-safe. Writes are serialized with
flockand committed atomically (temp file +rename), so simultaneous hits can't lose updates.
-
Copy
count.phpinto your web root. -
Show the number. Drop this anywhere on your page (see
example/index.htmlfor a complete page):<span id="site-counter" hidden></span> <script type="module"> const el = document.getElementById('site-counter'); try { const r = await fetch('/count.php'); if (r.ok) { const { total } = await r.json(); if (typeof total === 'number') { el.textContent = `👁 ${total.toLocaleString()} views`; el.hidden = false; } } } catch (_) { /* leave hidden on failure */ } </script>
The span starts
hiddenand only appears once the count loads, so if PHP is ever unavailable your page looks normal — no broken widget. -
Let PHP write its data dir.
count.phpself-createscounter-data/next to itself, but only if the web root is writable by the PHP user. The reliable way is to pre-create it owned by the PHP-FPM user:sudo install -d -o www-data -g www-data /path/to/webroot/counter-data
-
Apache / typical shared hosting: nothing to do — PHP files execute by default. It just works.
-
nginx: PHP isn't wired per-file by default. Add a scoped block so only this file executes PHP (don't enable PHP across the whole web root):
location = /count.php { include snippets/fastcgi-php.conf; fastcgi_pass unix:/run/php/php8.2-fpm.sock; # match your PHP-FPM socket }
Then
nginx -t && systemctl reload nginx.
Edit them in the file, or define() them before including it:
COUNTER_TRUST_FORWARDED_FOR(defaultfalse) — settrueonly when behind a trusted proxy (e.g. Cloudflare) so the real client IP is read fromX-Forwarded-For. Leaving it off avoids IP spoofing.COUNTER_SKIP_BOTS(defaulttrue) — skip common crawler user-agents so they don't inflate the count.COUNTER_DATA_DIR(defaultcounter-data/next to the script) — where state lives.
Migrating from another counter? Set the starting total directly (the file is just JSON):
// run once, e.g. as the PHP-FPM user
$f = 'counter-data/counter.json';
$s = json_decode(file_get_contents($f), true) ?: ['date' => gmdate('Y-m-d'), 'total' => 0, 'seen' => []];
$s['total'] = 37000;
file_put_contents($f, json_encode($s));counter-data/counter.json holds:
{ "date": "2026-06-07", "total": 37042, "seen": { "<hash>": 1 } }seen contains only today's hashes and resets at UTC midnight, so it never
grows unbounded. On each request the visitor's IP is hashed with a per-install
random salt plus the date — sha256(salt + date + ip) — and counted only if
that hash hasn't been seen yet today.
Raw IPs are never stored. The per-install salt lives in counter-data/salt
(mode 0600) and never leaves the server, so the stored hashes aren't reversible
to IPs even if the JSON leaks. Because the salt is combined with the date and the
seen set is wiped daily, there's no cross-day linkage of visitors either.
php tests/count_test.php # unit: increment, dedupe, rollover, bot-skip, privacy
sh tests/count_concurrency_test.sh # 50 parallel hits -> total is exactly 50 (flock proof)Requires the php CLI. The code targets PHP 7.4+ and runs unchanged on 8.x.
MIT © Timothy D Beach