emielburgman / symfony-visit-storage
The storage half of a JS visit beacon: the INSERT IGNORE dedupe recipe, the Doctrine columns as a trait, and a retention purge command. Shared by portfolio, wealth-monitor and trading-bot.
Package info
bitbucket.org/emielburgman/symfony-visit-storage
Type:symfony-bundle
pkg:composer/emielburgman/symfony-visit-storage
Requires
- php: >=8.2
- doctrine/dbal: ^3.6|^4.0
- doctrine/orm: ^2.15|^3.0
- symfony/config: ^6.4|^7.0
- symfony/console: ^6.4|^7.0
- symfony/dependency-injection: ^6.4|^7.0
- symfony/http-kernel: ^6.4|^7.0
Requires (Dev)
- phpunit/phpunit: ^10.5|^11.0|^12.0
This package is not auto-updated.
Last update: 2026-07-30 15:07:48 UTC
README
The storage half of a JS visit beacon: the INSERT IGNORE dedupe recipe, the
Doctrine columns as a trait, and a retention purge command. Companion to
emielburgman/symfony-visitor-beacon,
which owns the semantics (what is a bot, what is the same visitor, what is the
same visit).
MIT.
Why now, when this was argued against before
At two apps the maths said no: the storage had already diverged, and forcing one shape would have bought a data migration for nothing. At three — portfolio, wealth-monitor, trading-bot — the copies had drifted in every way a copied method can, and none of it was a decision:
record() | purgeOlderThan() | flag column | |
|---|---|---|---|
| portfolio | (hash, authenticated), computes the bucket itself | int $days = 30, DQL delete, is_numeric guard | authenticated |
| wealth-monitor | (hash, authenticated), computes the bucket itself | int $days = 30, DQL delete, direct cast | authenticated |
| trading-bot | (hash, bucket, isGuest, ?now), bucket passed in | DateTimeImmutable $cutoff, raw SQL delete | is_guest — the inverse |
Three signatures for two operations, and a flag whose polarity flips between apps. The SQL is now in one place; the disagreements that are real stay in the apps.
What is deliberately not here
- The entity. Each app declares its own, using the trait for the shared columns and adding its own flag.
- The unique index. It belongs on the concrete entity — see below, this is the one thing that can break silently.
- The admin widget. Three design systems (Tailwind 4 + Alpine, Tailwind 3 + Stimulus, Tailwind + Stimulus/LiveComponents).
- The controller/route. Each app's beacon endpoint has its own auth context.
- Migrations. Adopting this package changes no schema, so there is nothing to migrate.
- How the purge is triggered. portfolio and wealth-monitor run it from Symfony Scheduler; trading-bot has no Scheduler and uses a crontab line. That is a real difference and it stays in the app.
Install
composer require emielburgman/symfony-visit-storage
// config/bundles.php
Emielburgman\VisitStorage\VisitStorageBundle::class => ['all' => true],
# config/packages/visit_storage.yaml — every key is optional
visit_storage:
table: site_visit
retention_days: 30
purge_command_name: 'app:purge-site-visits' # keep what your crontab calls
Using it
The entity keeps the trait and the index together, on purpose:
#[ORM\Entity(repositoryClass: SiteVisitRepository::class)]
#[ORM\Index(columns: ['visited_at'], name: 'idx_site_visit_visited_at')]
#[ORM\UniqueConstraint(name: 'uniq_site_visit_visitor_bucket', columns: ['visitor_hash', 'bucket'])]
class SiteVisit
{
use SiteVisitFields;
#[ORM\Column]
private bool $authenticated; // or is_guest — your polarity, your column
public function __construct(string $visitorHash, int $bucket, bool $authenticated)
{
$this->initVisit($visitorHash, $bucket);
$this->authenticated = $authenticated;
}
}
The unique constraint is load-bearing. INSERT IGNORE deduplicates by
colliding with it; without it every repeat beacon inserts a new row and the
counts inflate quietly — no error, no log, just wrong numbers. That is why the
columns ship as a trait rather than a mapped superclass: a superclass cannot
declare table-level constraints, so the columns would live in the parent while
the thing that gives them meaning lives in the child.
Recording, from your repository or controller:
$this->store->record($visitorHash, $bucket->for($now), ['authenticated' => $isLoggedIn ? 1 : 0]);
record() returns whether a row was actually written. That is not
decoration: the content-view counters bump denormalised totals only when the
ledger genuinely gains a row, and re-deriving that with a second query reopens
the race INSERT IGNORE closes.
Tests
composer install && vendor/bin/phpunit
The tests assert the generated SQL, which is normally a smell — here the
statement is the product. INSERT IGNORE is not interchangeable with a plain
INSERT (every repeat beacon becomes a duplicate-key error on a hot
fire-and-forget endpoint) nor with ON DUPLICATE KEY UPDATE (which would change
what "a new visit" means for the counters).
Column names are interpolated into the SQL while values are always bound, so
record() rejects any extra-column name that is not a plain identifier. Nothing
in the apps passes a request value there; the check is what keeps it that way.