Skip to content

Rollups, sessions & retention

Three commands keep the data usable. Two are scheduled by the package, one is always up to you. Full signatures: Artisan commands.

Overview and Campaigns never scan raw events. They read visit_stats_daily: one row per date, tenant, metric, dimension and value.

Terminal window
php artisan visits:aggregate # today
php artisan visits:aggregate --date=yesterday
php artisan visits:aggregate --date=2026-09-01
php artisan visits:aggregate --from=2026-09-01 --to=2026-09-30

For each day and each tenant it deletes the existing rows and inserts freshly computed ones, so running it again is safe. Days are calendar days in the app timezone.

MetricCountsRows of the day by
visitorsNew visitorsfirst_seen_at
sessionsSessionsstarted_at
page_viewspage_view eventscreated_at
conversionsaction eventscreated_at

Bots are excluded from every metric. Each metric gets a total row (dimension = '') plus one row per value of every dimension in aggregate.dimensions. Visitors are grouped by their first-touch attribution and their last-known geo and device, sessions by their own columns, events by their session’s columns plus the event name.

Closing sessions — visits:close-stale-sessions

Section titled “Closing sessions — visits:close-stale-sessions”
Terminal window
php artisan visits:close-stale-sessions

Finds sessions with ended_at = null whose last_activity_at is older than session_timeout_minutes and sets:

  • ended_at — the last activity time
  • duration_seconds — from started_at to the last activity
  • exit_url — URL of the session’s last page view

Tracking does not depend on it — a new event after the timeout opens a new session anyway. Without the command sessions just never get an end, duration or exit URL, and “online now” is unaffected.

Terminal window
php artisan visits:prune # older than retention_days (90)
php artisan visits:prune --days=180
php artisan visits:prune --force # no confirmation prompt

Deletes events created before the cutoff, sessions started before it and visitors last seen before it. Foreign keys cascade, so deleting an old session or visitor also deletes its newer children. visit_stats_daily is not touched — the dashboard totals for old days survive.

--days=0 (or retention_days = 0) means a cutoff of “now” — everything is deleted.

With schedule.enabled = true (default, VISITS_SCHEDULE_ENABLED) the service provider registers:

CommandFrequency
visits:close-stale-sessionsevery five minutes
visits:aggregate --date=todayevery five minutes
visits:aggregate --date=yesterdaydaily at 00:10

So the dashboard lags real traffic by up to five minutes plus the queue delay. The yesterday run picks up events that arrived after the last run of the previous day.

visits:prune is never scheduled by the package — deleting data stays an explicit decision:

routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('visits:prune --force')->daily()->when(fn () => config('visits.retention_days') > 0);

--force is required here: under the scheduler there is no terminal, the confirmation prompt answers “no” and nothing is deleted.

To change the frequencies, or when the commands already are in your own schedule (otherwise they run twice), turn the package schedule off and add them yourself:

VISITS_SCHEDULE_ENABLED=false
routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('visits:close-stale-sessions')->everyFiveMinutes()->onOneServer();
Schedule::command('visits:aggregate --date=today')->everyFiveMinutes()->withoutOverlapping()->onOneServer();
Schedule::command('visits:aggregate --date=yesterday')->dailyAt('00:10')->onOneServer();

Since 0.13.4 the package schedule uses withoutOverlapping() and onOneServer() on every command. onOneServer() needs a cache store with atomic locks shared by all servers (Redis, Memcached, database, DynamoDB).