New

Just launched: Vueblocks, a brand-new search engine for Vue packages! Explore Vueblocks →

jeffersongoncalves/laravel-page-visits

Track page-view visits on any Laravel app's public routes, with device/browser/geo/locale/referer/UTM capture
2,213 2
Install
composer require jeffersongoncalves/laravel-page-visits
Latest Version:1.8.0
PHP:^8.2
Laravel:12 · 13
License:MIT
Last Updated:Nov 4, 2026
Links: GitHub  ·  Packagist
Maintainer: jeffersongoncalves

Laravel Page Visits

Laravel Page Visits

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads License

Tracks page-view visits on any Laravel app's public GET routes: one row per page hit, capturing device/browser/GeoIP/locale/referer/UTM safely — anonymized IP, bot flagging, and GDPR export/ erasure — on top of jeffersongoncalves/laravel-visitor-fingerprint for every fingerprinting primitive.

Installation

You can install the package via composer:

composer require jeffersongoncalves/laravel-page-visits

Publish the config file and run the migration:

php artisan vendor:publish --tag="page-visits-config"
php artisan migrate

Usage

Once installed, the package automatically appends TrackPageVisit to the web middleware group (disable via page-visits.auto_register_middleware) and every GET request not matched by page-visits.exclude gets a page_visits row — no further setup required.

use JeffersonGoncalves\LaravelPageVisits\Models\PageVisit;

PageVisit::query()->latest('visited_at')->first();

GDPR / LGPD export and erasure

use JeffersonGoncalves\LaravelPageVisits\Compliance\PageVisitPersonalDataService;

$service = new PageVisitPersonalDataService;

$service->exportForIp($ip);  // matching rows as arrays
$service->forgetForIp($ip);  // nulls ip_hash/ip_anonymized/ip_version/user_agent_hash in place

Daily aggregation and retention pruning

page_visits grows unbounded otherwise. The package self-schedules page-visits:aggregate-and-prune daily at 02:30 (configurable via page-visits.scheduling.aggregate_and_prune) — no setup needed, as long as the host app's scheduler is running (schedule:run on cron, or schedule:work locally). It folds each day's raw rows into page_visit_daily_stats and deletes raw rows older than page-visits.retention_days.

Opt out (e.g. to run it yourself with different args) via config or env:

'scheduling' => [
    'aggregate_and_prune' => [
        'enabled' => false, // PAGE_VISITS_SCHEDULE_AGGREGATE_AND_PRUNE
    ],
],

Run it for a specific day (e.g. to backfill) with --date:

php artisan page-visits:aggregate-and-prune --date=2026-09-01

page_visit_daily_stats holds one row per date with visits_count, unique_visits_count, bot_visits_count, datacenter_visits_count, plus JSON breakdowns (device_stats, browser_stats, os_stats, country_stats, city_stats, referer_stats, referer_type_stats, utm_source_stats, utm_medium_stats, utm_campaign_stats, language_stats, hourly_stats) — query it directly via DB::table(config('page-visits.daily_stats_table')) for "last 7/30/90 days" reporting instead of scanning raw rows.

response_time_stats holds that day's server response times in milliseconds (bots included, unless store_bots is off): {"count": 1200, "avg": 180, "min": 12, "max": 1654, "p50": 140, "p95": 520, "p99": 980}, or null when no visit was timed. Upgrading from an older release? Republish and run the new migrations (response_time_stats, datacenter_visits_count):

php artisan vendor:publish --tag="page-visits-migrations"
php artisan migrate

Configuration

// config/page-visits.php
return [
    'table' => 'page_visits',
    'daily_stats_table' => 'page_visit_daily_stats',

    'track_ip_address' => true,
    'track_browser' => true,
    'track_browser_version' => true,
    'track_operating_system' => true,
    'track_operating_system_version' => true,
    'track_device_type' => true,
    'track_referer_url' => true,
    'track_browser_language' => true,

    'exclude' => [
        'livewire/update', 'admin/*', 'app/*', 'horizon/*', '_debugbar/*',
        'sw.js', 'manifest.json', 'favicon-proxy', 'up', 'og/*',
    ],

    'exclude_route_names' => [],

    'store_bots' => env('PAGE_VISITS_STORE_BOTS', true),

    'require_accept_language' => env('PAGE_VISITS_REQUIRE_ACCEPT_LANGUAGE', true),

    'farm_ua_ip_threshold' => env('PAGE_VISITS_FARM_UA_IP_THRESHOLD'),

    'skip_status_codes' => [],

    'datacenter_asns' => ['AS14618', 'AS16509', 'AS15169', 'AS396982', /* ... */],
    'store_datacenter' => env('PAGE_VISITS_STORE_DATACENTER', true),

    'auto_register_middleware' => true,

    'retention_days' => env('PAGE_VISITS_RETENTION_DAYS', 400),

    'scheduling' => [
        'aggregate_and_prune' => [
            'enabled' => env('PAGE_VISITS_SCHEDULE_AGGREGATE_AND_PRUNE', true),
            'time' => env('PAGE_VISITS_SCHEDULE_AGGREGATE_AND_PRUNE_TIME', '02:30'),
        ],
    ],

    'compliance' => [
        'analytics_only' => env('PAGE_VISITS_ANALYTICS_ONLY', false),
    ],
];

Every track_* toggle nulls out its own column instead of skipping the row — the visit is always recorded, just with less detail when a toggle is off. compliance.analytics_only additionally nulls every PII column (ip_hash, ip_anonymized, ip_version, user_agent_hash).

Set store_bots to false (PAGE_VISITS_STORE_BOTS=false) to skip crawler requests entirely: no raw row is written for them, so bot_visits_count stays 0 and response_time_stats covers human visits only. This affects only visits recorded after the switch: bot rows already in page_visits are still folded into the daily stats (e.g. the day you flip it, or a --date backfill) until retention_days prunes them. Bots are often the bulk of page_visits, so this keeps the table small without shortening retention_days for human visits.

Crawler farms behind rotating residential proxies send browser-like user-agents from residential ASNs, so bot detection and datacenter_asns both miss them. Two signals catch them:

  • require_accept_language (default true): a heuristic — mainstream browsers send Accept-Language, so a request without one is flagged is_bot, and skipped entirely when store_bots is false. Some privacy proxies and extensions strip the header, so a few genuine visits can be flagged (or dropped). Disable it with PAGE_VISITS_REQUIRE_ACCEPT_LANGUAGE=false.

  • farm_ua_ip_threshold (default null, off): page-visits:aggregate-and-prune counts the day's rows as bots when their exact user-agent came from more than this many distinct IPs, moving them from visits_count / unique_visits_count to bot_visits_count. Only the daily stats change — raw is_bot is left as captured, so changing the threshold and re-running the command (--date) recomputes the day. Scale it with traffic — modern browsers send reduced, identical UA strings, so on a busy site a popular real browser can exceed a small value. A low-traffic site might use 20. Needs ip_hash and user_agent_hash, so it does nothing under compliance.analytics_only.

    To apply the same cut in your own queries (dashboards, trending lists), use the model instead of copying the query:

    use JeffersonGoncalves\LaravelPageVisits\Models\PageVisit;
    
    // user_agent_hash values over the threshold in [$from, $to); empty when the threshold is off
    PageVisit::farmUserAgentHashes($from, $to);
    
    // rows minus farm user agents (rows with a null user_agent_hash are kept)
    PageVisit::query()->where('is_bot', false)->excludingFarms($from, $to)->count();
    

skip_status_codes lists response statuses that are not stored at all — exact codes (404) or x wildcard ranges ('4xx', '50x'). Scanner probes often send browser-like user-agents from residential ASNs, so neither store_bots nor datacenter_asns catches them; [404] keeps those probes out of page_visits and visits_count. Empty (default) stores every status.

Headless scrapers on cloud hosts send real browser user-agents, so bot detection misses them. datacenter_asns lists cloud/hosting ASNs (AWS, Google Cloud, Azure, Tencent, Huawei, DigitalOcean, OVH, Hetzner by default — AS prefix optional); a visit whose GeoIP ASN matches is stored with is_datacenter = true and left out of visits_count / unique_visits_count. Matching non-bot visits are counted in datacenter_visits_count; matching bot visits stay in bot_visits_count only. This needs a GeoIP driver that resolves the ASN (e.g. ip_api). Set store_datacenter to false (PAGE_VISITS_STORE_DATACENTER=false) to drop those visits entirely.

daily_stats_table and retention_days are read by page-visits:aggregate-and-prune — see Daily aggregation and retention pruning above.

GeoIP resolution, bot detection, IP anonymization, and referer classification are all delegated to jeffersongoncalves/laravel-visitor-fingerprint — see its README to configure the GeoIP driver (headers/ip_api/maxmind).

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security

If you discover any security related issues, please email the author instead of using the issue tracker.

Credits

License

The MIT License (MIT). Please see License File for more information.

Related Packages

jeffersongoncalves/filament-page-visits

Filament admin resource for jeffersongoncalves/laravel-page-visits — browse and...

1,905 4
codeaken/laravel-piwiktracker

Insertion of Piwik tracking code and a hidden mode for hiding your tracking serv...

100 3
bleckert/openpanel-laravel

Laravel provider for Openpanel

20,486 6
cornford/googlitics

An easy way to integrate Google Analytics with Laravel.

10,229 32
artisanpack-ui/analytics

Privacy-first analytics package for Laravel with local database storage, externa...

4,774 0

Version History

Version Released PHP Laravel License
1.8.0 ^8.2 ^12.0|^13.0 MIT
1.7.0 ^8.2 ^12.0|^13.0 MIT
1.6.0 ^8.2 ^12.0|^13.0 MIT
1.5.1 ^8.2 ^12.0|^13.0 MIT
1.5.0 ^8.2 ^12.0|^13.0 MIT