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.

Maintainers

Package info

bitbucket.org/emielburgman/symfony-visit-storage

Type:symfony-bundle

pkg:composer/emielburgman/symfony-visit-storage

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v1.0.0 2026-07-29 23:31 UTC

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 itselfint $days = 30, DQL delete, is_numeric guardauthenticated
wealth-monitor(hash, authenticated), computes the bucket itselfint $days = 30, DQL delete, direct castauthenticated
trading-bot(hash, bucket, isGuest, ?now), bucket passed inDateTimeImmutable $cutoff, raw SQL deleteis_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.