Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

site-counter

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

Why

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.php to 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 flock and committed atomically (temp file + rename), so simultaneous hits can't lose updates.

Install (3 steps)

  1. Copy count.php into your web root.

  2. Show the number. Drop this anywhere on your page (see example/index.html for 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 hidden and only appears once the count loads, so if PHP is ever unavailable your page looks normal — no broken widget.

  3. Let PHP write its data dir. count.php self-creates counter-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

Web server notes

  • 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.

Config knobs (top of count.php)

Edit them in the file, or define() them before including it:

  • COUNTER_TRUST_FORWARDED_FOR (default false) — set true only when behind a trusted proxy (e.g. Cloudflare) so the real client IP is read from X-Forwarded-For. Leaving it off avoids IP spoofing.
  • COUNTER_SKIP_BOTS (default true) — skip common crawler user-agents so they don't inflate the count.
  • COUNTER_DATA_DIR (default counter-data/ next to the script) — where state lives.

Seeding an existing count

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));

How it works

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.

Privacy

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.

Tests

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.

License

MIT © Timothy D Beach

About

A single-file, privacy-respecting PHP visitor counter — once per IP per UTC day, no database, no dependencies.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages