jeffersongoncalves/laravel-page-visits
| 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 |

Laravel Page Visits
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(defaulttrue): a heuristic — mainstream browsers sendAccept-Language, so a request without one is flaggedis_bot, and skipped entirely whenstore_botsisfalse. Some privacy proxies and extensions strip the header, so a few genuine visits can be flagged (or dropped). Disable it withPAGE_VISITS_REQUIRE_ACCEPT_LANGUAGE=false. -
farm_ua_ip_threshold(defaultnull, off):page-visits:aggregate-and-prunecounts the day's rows as bots when their exact user-agent came from more than this many distinct IPs, moving them fromvisits_count/unique_visits_counttobot_visits_count. Only the daily stats change — rawis_botis 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 use20. Needsip_hashanduser_agent_hash, so it does nothing undercompliance.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
Filament admin resource for jeffersongoncalves/laravel-page-visits — browse and...
Insertion of Piwik tracking code and a hidden mode for hiding your tracking serv...
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| 1.8.0 | ^8.2 | ^12.0| | MIT | |
| 1.7.0 | ^8.2 | ^12.0| | MIT | |
| 1.6.0 | ^8.2 | ^12.0| | MIT | |
| 1.5.1 | ^8.2 | ^12.0| | MIT | |
| 1.5.0 | ^8.2 | ^12.0| | MIT | |
| 1.4.0 | ^8.2 | ^12.0| | MIT | |
| 1.3.0 | ^8.2 | ^12.0| | MIT | |
| 1.2.0 | ^8.2 | ^12.0| | MIT | |
| 1.1.0 | ^8.2 | ^12.0| | MIT | |
| 1.0.3 | ^8.2 | ^12.0| | MIT | |
| 1.0.2 | ^8.2 | ^12.0| | MIT | |
| 1.0.0 | ^8.2 | ^12.0| | MIT |