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.
Package info
bitbucket.org/emielburgman/symfony-visitor-beacon
Type:symfony-bundle
pkg:composer/emielburgman/symfony-visitor-beacon
Requires
- php: >=8.2
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-foundation: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
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 included | Why |
|---|---|
| Entity / repository / migrations | Consumers 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 route | A route is three lines and every app wants its own path, firewall placement and auth handling. |
| Twig partial | CSP nonce handling and asset conventions differ per app. |
| Admin dashboard / widgets | Design 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.