emielburgman/symfony-visitor-beacon

Anonymous visitor identification for JS-beacon based analytics in Symfony: salted IP+User-Agent hashing, bot filtering and time bucketing, with no storage opinions.

Maintainers

Package info

bitbucket.org/emielburgman/symfony-visitor-beacon

Type:symfony-bundle

pkg:composer/emielburgman/symfony-visitor-beacon

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v1.0.0 2026-07-28 13:26 UTC

This package is not auto-updated.

Last update: 2026-07-30 15:14:49 UTC


README

Anonymous visitor identification for JS-beacon analytics in Symfony. Salted IP + User-Agent hashing, bot filtering, and time bucketing — and nothing else. No entities, no routes, no controllers, no Twig, no migrations.

Why this exists

Counting page views while serving the page does not measure people. Crawlers present browser-like User-Agents, and a User-Agent blocklist alone barely dents it. On one of the sites this was extracted from, 354 of 357 daily "visitors" counted server-side were bots.

What does work is inverting the test: count only requests that a browser executes JavaScript to make. Crawlers overwhelmingly do not. The page renders, then fires a beacon at an endpoint that records the visit:

page load → JS beacon → POST /ping → record(hash, bucket)

Session-based "unique view" logic has the same flaw in a subtler form: every crawler gets a fresh session, so it is counted as a new reader every time. Deduplicate on a durable, salted hash instead.

This package owns the parts of that pattern that are worth agreeing on between applications. It deliberately does not own storage — see Scope.

Installation

composer require emielburgman/symfony-visitor-beacon

Register the bundle in config/bundles.php:

Emielburgman\VisitorBeaconBundle\VisitorBeaconBundle::class => ['all' => true],

Usage

use Emielburgman\VisitorBeaconBundle\Service\TimeBucket;
use Emielburgman\VisitorBeaconBundle\Service\VisitorIdentifier;

class VisitBeaconController extends AbstractController
{
    public function __construct(
        private readonly VisitorIdentifier $identifier,
        private readonly TimeBucket $bucket,
        private readonly SiteVisitRepository $repository,
    ) {
    }

    #[Route('/ping', name: 'app_visit_ping', methods: ['POST'])]
    public function ping(Request $request): Response
    {
        $hash = $this->identifier->forRequest($request);

        // Null means a bot, or no User-Agent at all: nothing to count.
        if ($hash !== null) {
            $this->repository->record($hash, $this->bucket->for());
        }

        return new Response(status: Response::HTTP_NO_CONTENT);
    }
}

The beacon itself, in your layout:

<script nonce="{{ csp_nonce }}">
    (function () {
        if (location.pathname.indexOf('/admin') === 0) { return; }
        var url = '{{ path('app_visit_ping') }}';
        if (navigator.sendBeacon) {
            navigator.sendBeacon(url);
        } else {
            fetch(url, {method: 'POST', keepalive: true});
        }
    }());
</script>

Storing a visit

Deduplication is a database concern, not an application one. Put a unique index on (visitor_hash, bucket) and let INSERT IGNORE absorb repeats — concurrent beacons then cannot race into a duplicate-key error:

CREATE TABLE site_visit (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    visitor_hash  VARCHAR(64) NOT NULL,
    bucket        INT NOT NULL,
    visited_at    DATETIME NOT NULL,
    UNIQUE INDEX uniq_visitor_bucket (visitor_hash, bucket),
    INDEX idx_visited_at (visited_at)
);
$this->connection->executeStatement(
    'INSERT IGNORE INTO site_visit (visitor_hash, bucket, visited_at) VALUES (:hash, :bucket, :now)',
    ['hash' => $hash, 'bucket' => $bucket, 'now' => (new DateTimeImmutable())->format('Y-m-d H:i:s')],
);

Visit rows are personal data under GDPR even when hashed — pseudonymised is not anonymised. Purge them on a retention window rather than keeping them forever.

Configuration

Everything has a working default; the whole file is optional.

# config/packages/visitor_beacon.yaml
visitor_beacon:
    # Salt for the visitor hash. Must be a real secret.
    secret: '%kernel.secret%'

    # Deduplication window in seconds. Repeat beacons from one visitor
    # inside the same window are the same visit.
    bucket_seconds: 300

    # Replaces the built-in list entirely when set. Case-insensitive
    # substrings, matched anywhere in the User-Agent.
    bot_signatures: []

Privacy

  • The stored value is sha256(secret | ip | user-agent) — the raw IP and User-Agent are never persisted.
  • The salt matters. IPv4 space is small enough to enumerate; an unsalted digest of IP + User-Agent can be brute-forced straight back to an IP. An empty secret is rejected at construction rather than silently accepted.
  • Rotating the secret invalidates every existing hash. That is a feature for privacy and a gotcha for continuity: old and new rows will not join.

Scope: what is deliberately not here

Storage, routing and markup stay in the application:

Not includedWhy
Entity / repository / migrationsConsumers disagree on shape — one keys views by slug per content type, another uses a single polymorphic ledger. Forcing one shape would mean a data migration for no benefit.
Controller and routeA route is three lines and every app wants its own path, firewall placement and auth handling.
Twig partialCSP nonce handling and asset conventions differ per app.
Admin dashboard / widgetsDesign systems differ.

What is left is the part where two applications genuinely must agree: what counts as a bot, what counts as the same visitor, and what counts as the same visit. That is the part that silently drifts when copy-pasted, and the only part where a divergence corrupts the numbers.

Migrating from an inline implementation

If you are replacing a hand-rolled preg_match('~bot|crawl|spider|…~i', $ua) plus an empty-string guard, this package is behaviourally identical on every real User-Agent — differential-tested against that exact pattern across browsers, crawlers and tooling agents.

There is one deliberate difference: a User-Agent consisting only of whitespace (" ") was previously counted as a visitor and is now treated as a bot. isBot() trims before testing for emptiness. No real browser sends one.

Testing

composer install
vendor/bin/phpunit

License

MIT — see LICENSE.