DEV3 Technologies home

Cookieless pageview collector

A tracking endpoint that identifies visitors with a daily-rotating salted hash instead of cookies, so no personal data is ever stored.

LaravelAPIsanalyticsprivacycookielesshashing

Code

<?php

namespace App\Http\Controllers;

use App\Analytics\VisitorHasher;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;

class CollectController extends Controller
{
    public function __invoke(Request $request): Response
    {
        // sendBeacon posts text/plain, so decode the body ourselves.
        $data = validator(json_decode($request->getContent(), true) ?? [], [
            'site' => ['required', 'integer', 'exists:sites,id'],
            'path' => ['required', 'string', 'max:500'],
            'ref' => ['nullable', 'string', 'max:500'],
        ])->validate();

        DB::table('pageviews')->insert([
            'site_id' => $data['site'],
            // IP and user agent are used for hashing only and never stored.
            'visitor' => VisitorHasher::make($data['site'], (string) $request->ip(), (string) $request->userAgent()),
            'path' => parse_url($data['path'], PHP_URL_PATH) ?: '/',
            'referrer' => parse_url($data['ref'] ?? '', PHP_URL_HOST),
            'created_at' => now(),
        ]);

        return response()->noContent(202);
    }
}

How it works

What it solves

Cookie banners exist because analytics scripts store identifiers. You can count unique visitors without any storage on the device by deriving an anonymous id on the server, and discarding it before it can be linked across days.

How it works

VisitorHasher hashes the site id, a normalised IP and the lowercased user agent together with a secret salt. The salt is created fresh each UTC day and kept only for that day, so the same person gets a different id tomorrow and old ids can never be recomputed once the salt is deleted. IPv6 addresses are cut to their /64 so privacy extensions do not inflate the count. The IP and user agent are inputs only and are never saved.

CollectController validates a small payload (path, referrer, an optional screen width), builds the hash, and inserts a row with the hash, path and timestamp. Salts live in a daily_salts table; firstOrCreate makes the first request of the day generate one, and a nightly job deletes salts older than a day.

The endpoint returns 202 with no body, which suits navigator.sendBeacon.

Adapting it

Add a site key lookup, a rate limit and bot filtering before the insert. Roll the salt at midnight in the site's timezone if you want local-day uniques.

Pitfalls

  • Never log the raw IP in access logs for this route if you promise not to store it.
  • Behind a proxy, configure trusted proxies so ip() is the client, not the load balancer.

How to use it

  1. Create daily_salts (day date unique, salt string) and pageviews (site_id, visitor, path, referrer, created_at).
  2. Copy both classes; route Route::post('/collect', CollectController::class)->middleware('throttle:600,1').
  3. Send from the browser with navigator.sendBeacon('/collect', JSON.stringify({site: 1, path: location.pathname, ref: document.referrer})).
  4. Schedule DB::table('daily_salts')->where('day', '<', today())->delete() nightly.
  5. Count uniques with distinct visitor.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets