PHP analytics without cookies or a database — and without violating GDPR

Table of contents (5 sections)

I wanted to know which pages of my portfolio are visited, where the traffic comes from, and whether anyone actually reads the blog — without plugging in Google Analytics, without setting up a database, without displaying a cookie banner. The constraint: Apache + pure PHP hosting, nothing else.

Why not Google Analytics

GA4 would have taken 5 minutes. But two concrete problems:

  • GDPR: the moment you use GA, personal data (IP, behavior) goes to Google. Legal obligation to display a consent banner, handle refusals, maintain a processing register. For a personal portfolio, that's disproportionate.
  • Blocked: uBlock Origin, Brave, Firefox Enhanced Tracking Protection — GA is blocked by a significant portion of developers who are precisely my audience. The stats would be underreported.

Self-hosted alternatives like Umami or Plausible require Node.js + PostgreSQL. Not available here.

The solution: file log, hashed IP, zero cookies

A tracker.php file included at the top of each page. It writes a line to logs/visits.tsv on every visit. That's it. The full file (73 lines, no dependencies) is in this gist.

The real value is in the security decisions:

1. No cookies set — therefore no consent required

GDPR mandates consent only when storing personal data on the user's side (cookies) or when identifying a natural person. None of that here. The server records a line and that's it — the user is unaware, feels nothing, and it's legal.

2. IP anonymized before storage

An IP address is personal data under GDPR. We never store it in plain text. Instead, we compute a salted SHA-256 hash, truncated to 16 characters:

$ip_hash = substr(
    hash('sha256', ($_SERVER['REMOTE_ADDR'] ?? '') . 'cv_salt_xK9!2026'),
    0, 16
);

The salt makes the hash irreversible even with a rainbow table. The result allows counting unique visitors without ever being able to recover the original IP. This is exactly what France's CNIL recommends for the consent exemption.

3. Bot filtering upfront

Without filtering, the log file grows quickly with useless traffic — Google crawlers, Bing, scrapers, monitoring tools. A regex on the User-Agent eliminates the majority:

if (preg_match('/bot|crawl|spider|slurp|wget|curl|libwww|python|java|Go-http/i', $ua)) {
    return; // Exit immediately, nothing is logged
}

This doesn't catch bots that disguise themselves as humans, but it covers 95% of real automated traffic.

4. Logs directory inaccessible from the web

The visits.tsv file contains IP hashes, URLs, and timestamps. Even anonymized, it must not be publicly accessible. A .htaccess in the logs/ directory is sufficient:

Deny from all

Apache blocks any HTTP request to this directory. PHP on the server can still write to it internally — only browser access is blocked.

5. Isolated variables to avoid polluting the global scope

The tracker is included via require in index.php, so it shares the same PHP scope. To avoid overwriting existing variables or exposing internal data, all the tracker code runs inside an anonymous function that is called right away: its variables vanish when it ends, even when it exits early because the visitor is a bot. My first version prefixed every variable with $_ and deleted them with unset(), but that cleanup was skipped on every early exit.

The dashboard

A password-protected stats.php reads the TSV file, calculates KPIs, and displays them with Chart.js (CDN). No complex persistent session — just a standard PHP $_SESSION and a password checked with password_verify against a hash, never stored in clear. A reduced, standalone version (visits per day, devices, pages, sources) is in the same gist.

KPIs displayed:

  • Total visits and unique visitors over 7 / 30 / 90 days
  • Visits today vs yesterday
  • Visits-per-day chart
  • Top pages visited
  • Traffic sources (direct / external / internal navigation)
  • Desktop / mobile / tablet breakdown
  • Hourly visit heatmap

What it doesn't do

No heatmaps, no funnels, no reconstructed sessions, no visit duration. For a portfolio, that level of detail is pointless. What matters: are people arriving, and which pages are they looking at.

The log file will grow. It will need periodic purging or log rotation (one file per month, for example). For now, a 100,000-line log weighs about 8 MB — PHP reads it in under a second.

Result

Zero cookies. Zero consent banner. Zero external production dependencies. Zero identifiable personal data stored. Functional dashboard in under 200 lines of PHP.

That's the right level of complexity for the actual need.

stats.php dashboard — KPIs, daily visits chart, top pages, traffic sources and hourly heatmap
stats.php in production — KPIs, visits/day, top pages, devices, hourly heatmap. Click to enlarge.

In the Developer's Great Book

A free book where each idea is told once, in its place, from silicon to human judgment.

Comments (2)

Tobias 25 Oct 2026, 12:01
Where is the code?
Odilon Hugonnot 27 Oct 2026, 13:12
Hi Tobias, thanks for asking. The article only showed fragments, so I've published the full code as a gist: https://gist.github.com/ohugonnot/6b294a9e012336b65721735caf18fad4. It has two files. tracker.php (73 lines, no dependencies) writes one line per visit to logs/visits.tsv. dashboard.php is a minimal stats page (visits per day, devices, top pages, sources, Chart.js) that reads that file. Define TRACKER_SALT and STATS_PASSWORD_HASH in a file kept out of version control, include the tracker at the top of each page, and you're set.