Search by

drcantagalo / laravel-monitor

Cantagalo

A lightweight Laravel package providing CRM tools, access monitoring, and anti-scraper protection. Requires Laravel's Cache facade (any driver).

Package info

github.com/DrCantagalo/laravel-monitor

pkg:composer/drcantagalo/laravel-monitor

Statistics

Installs: 362

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


README

Laravel Monitor is an experimental package designed to test the initial installation flow for a lightweight Laravel package providing basic CRM tools, access monitoring, and anti-scraper features. Designed to track visits, manage sessions, and detect potentially malicious scrapers.

⚠️ This is an early testing release.
API and config shape may still change between minor versions. See CHANGELOG.md for what each release actually added/fixed.

Requirements

The package depends on Laravel's Cache facade — any configured cache driver works, including the framework defaults file and database (no external service required). Redis and Memcached are supported as well. Since 0.30.0, a failure of the cache store itself (Redis/Memcached down, etc) no longer takes the blocking checks down with it: isBlocked()/isPathBlocked() fall back to querying the database directly instead of assuming a request is not blocked — see "Fail-safe against cache store outages" below.

Sanctum (optional, since 0.75.0)

laravel/sanctum is an optional dependency, used only by the api route-group tracking described in "API route tracking" below. If it is not installed, that feature simply degrades to IP-based tracking for every request — no error, no exception, nothing to configure. Install it (composer require laravel/sanctum) to get per-token grouping instead.

Updating

First-time setup is composer require drcantagalo/laravel-monitor followed by php artisan monitor:install (interactive: terms, publish config/migrations, migrate, main domain, register the installation). monitor:install is not meant to run again on every update — running it a second time would re-ask every prompt for a project that's already set up.

Main domain (since 0.70.0): right after the migration prompt (and before the dashboard question below), monitor:install asks for the site's main domain — plain text, with or without scheme/path/port, normalized down to a bare lowercase host (www. stripped too); an answer that doesn't look like a valid domain is rejected and asked again. Default is the host of config('app.url'). This is collected and persisted (main_domain in storage/monitor/installation.json) regardless of whether you use the hosted dashboard below, because it also drives local visitor-origin tagging — see "Visitor origin tagging" further down. If you do use the dashboard, the same value is reused as the site_url sent on registration (no separate URL question). Existing installations that predate this (e.g. upgrading from an older version) get asked the same question once, the next time monitor:update runs — see below.

Dashboard is optional (since 0.31.0): monitor:install asks whether you want to use the hosted dashboard/interface (monitor.cantagalo.it). Answering no skips the remote registration entirely — no local_token is sent anywhere, and the package's public route (/monitor/handler) is never registered (config('monitor.dashboard.enabled', true), checked in MonitorServiceProvider::boot()). You still get local tracking (the Monitor model, monitor:audit-paths, monitor:export-denylist, visitor origin tagging, etc) — just nothing sent to or exposed for the SaaS dashboard. Default is true (yes), so existing installations that never rerun monitor:install keep working exactly as before.

Declining is guaranteed to take effect (since 0.35.0): if config/monitor.php hasn't been published yet (you answered no to the config-publish prompt above), monitor:install publishes it itself before writing dashboard.enabled = false, so the route really is skipped instead of silently falling back to the true default. If the published file is from a version older than 0.31.0 and doesn't have the dashboard key at all, the command inserts the block instead of relying on the regex used for normal updates. In the rare case none of that manages to persist the value (e.g. config/monitor.php isn't writable), the command warns you explicitly instead of finishing silently, and tells you how to disable the route by hand ('dashboard' => ['enabled' => false] + php artisan config:clear, and php artisan route:clear too if routes are cached).

After a plain composer update drcantagalo/laravel-monitor (bumping to a new version of an already-installed project), run:

php artisan monitor:update

This keeps your published config/monitor.php (a static copy created by vendor:publish --tag=monitor-config) in sync with the package, without touching anything you've customized:

  • Adds any config key that's new in this version (with its explanatory comment carried over from the package template) — these keys are otherwise invisible in your published file until you update it, even though mergeConfigFrom() already covers them at runtime with the package default.
  • Never overwrites a key your published file already has — including dashboard_origin or any other value you've customized. If a key's value is still whatever the package used to default to (i.e. you never actually customized it and it's now stale), the command only reports it — updating it is your call, not the command's.
  • Updates the version key to the actually-installed version (read from Composer, not from the package template), so config('monitor.version') reported by monitor:install's handshake stays accurate.
  • Warns about any key your published file still has that no longer exists in the current template — usually means it was removed or renamed in a breaking change (check CHANGELOG.md).

Also asks for the site's main domain (since 0.70.0), same question/ default/normalization as monitor:install above, but only if storage/monitor/installation.json doesn't have main_domain yet — this is how an installation done before 0.70.0 migrates. Once the key exists, later runs never ask again.

Always safe to run again — it's idempotent (a second run with no new package version is a no-op).

composer update only refreshes vendor/ — it never runs migrate. Package migrations are auto-discovered at runtime (loadMigrationsFrom(), no publish needed), but still need to actually run against your database. If the command finds package migrations that haven't been applied yet, it lists them and asks (default yes) whether to run php artisan migrate right there — same pattern as the migration prompt in monitor:install.

monitor:update is fully translated (en/it/pt), same as monitor:install — but it doesn't ask which language to use every time, since it's meant to be run routinely rather than once. It reuses the language chosen during monitor:install (persisted in storage/monitor/installation.json); falls back to English if that file or the lang key isn't there (installations done before this).

Aggregated dashboard totals (getData)

GET /monitor/handler?action=getData — same auth as the other read actions (permanent local_token or the ephemeral read token from issueReadToken).

Breaking change in 0.10.0: this endpoint used to return every monitors row as-is ({"success": true, "data": [...]}), which meant loading the entire table into memory and serializing it in one response. That's fine on a fresh install, but it reliably exhausts PHP's memory_limit once the table grows into the tens of thousands of rows (confirmed in production at ~19.5k rows — see CHANGELOG [0.10.0]). getData now returns only pre-aggregated totals, computed without ever loading a full Monitor row into PHP:

{
  "success": true,
  "visitors_total": 19532,
  "visits_total": 84210,
  "unique_ips_total": 8117,
  "monitors_by_kind": {
    "human": 6120,
    "human_guest": 5480,
    "human_user": 640,
    "bot": 9874,
    "flagged": 412,
    "unclassified": 2879,
    "new": 247
  },
  "blocked_attempts_total": 342,
  "package_version": "0.47.0",
  "config_version": "0.46.0"
}
  • visitors_total: total Monitor rows — one row per recognized device/browser (see "Remember-me" above). Since 0.56.0 it is the sum of monitors_by_kind (same cached snapshot, excluding the legacy human key — see below), so the two always agree exactly.
  • visits_total: COUNT(*) of monitor_visits (see "Visits" below) — one row per new PHP session, not per page view. A visitor browsing multiple pages in the same session is one visit. Only counts visits recorded since 0.42.0 (until 0.41.0 this was a SUM of data.visits over the JSON blob, which is no longer written) and stays 0 with monitor.track_visits turned off.
  • sessions_total: removed in 0.56.0 (breaking). Since 0.42.0 a visit is a PHP session, so it was always the same number as visits_total — read visits_total instead.
  • monitors_by_kind (since 0.56.0, categories expanded in 0.59.0): every Monitor split into disjoint groups — "the Monitor is the source of truth" for the dashboard header.
    • human_guest/human_user (since 0.59.0): Monitors with kind=human, split by whether data.user_id is present (see "Authenticated user tagging" below). human is kept as human_guest + human_user, for consumers still reading the old key — excluded from the sum that must equal visitors_total (it's redundant with the two granular keys, summing it too would double-count every human Monitor).
    • bot: Monitors with kind=bot (any source — manual, AI, or the auth source doesn't apply here, only humans get auto-tagged).
    • flagged (new in 0.59.0): Monitors with no kind that were seen at a flagged or currently-blocked IP — same exclusion criteria getMonitorQueueCounts already used internally. A Monitor's own kind always wins: a classified human/bot seen at a flagged IP still counts by its classification, never flagged.
    • new/unclassified: Monitors with no kind and not flagged, split by created_at against the same ai_triage_min_age_hours cutoff the AI triage queue uses (see "IP classification" below) — new is younger than the cutoff, unclassified is at or past it. Since 0.59.0 these two no longer include flagged Monitors (before, they did) — this is why monitors_by_kind now matches getMonitorQueueCounts exactly, not just approximately.
    • human_guest + human_user + bot + flagged + unclassified + new always sums to exactly visitors_total.
    • Computed in a single aggregate query (left join on monitor_labels, conditional SUM(CASE …), no GROUP BY — the flagged/user-id breakdowns are expressed as id IN (...) against two ID sets computed once beforehand, not a second join). On an install not yet migrated to 0.53.0+ (no monitor_labels table) it fails open by counting every Monitor as having no kind.
  • unique_ips_total: IpStat::count() — reuses monitor_ip_stats (see "Per-IP stats" below), which already keeps exactly one row per unique IP ever seen. Deliberately not a dedupe of monitor_visit_ips across every Monitor.
  • blocked_attempts_total: unchanged, see "Blocked-attempt counter" below.
  • package_version (since 0.46.0): the actually-installed package version, read from Composer\InstalledVersions::getPrettyVersion( 'drcantagalo/laravel-monitor') — not from config('monitor.version'). Deliberately not the config value: config/monitor.php is published (vendor:publish --tag=monitor-config) as a static copy in the host project, so once published its 'version' key stays frozen at whatever it was the last time monitor:install/monitor:update wrote it — it drifts from the real installed version the moment the package is updated without running monitor:update right after (same problem MonitorUpdateCommand already solves for that specific key by reading InstalledVersions itself, ver MonitorUpdateCommand). getData always reflects what Composer actually installed. null if the package somehow isn't registered in Composer's installed.php (e.g. a manual symlink outside the normal Composer flow) — fails open, never breaks getData.
  • config_version (since 0.47.0): config('monitor.version') — the frozen value from the host project's published config/monitor.php, i.e. whatever monitor:install/monitor:update last wrote into it (see MonitorUpdateCommand). Sent deliberately alongside package_version, not instead of it: the two keys diverging (package_version ahead of config_version) is exactly the signal that the client ran composer update but never followed up with php artisan monitor:update — which means both the published config and the package's pending migrations are out of date (see MonitorUpdateCommand::confirmPendingMigrations()). Clients on a package version older than 0.47.0 don't send this key at all.
  • visitors_total/visits_total/unique_ips_total (added in 0.10.0) and monitors_by_kind (0.56.0) share a short, fixed cache TTL (config('monitor.data_totals_cache_ttl_seconds'), default 45 seconds — same rationale as block_results_cache_ttl_seconds) and fail open to 0 if the underlying table/column isn't there yet on an older, not-yet-migrated install (logged via Log::warning). package_version/config_version are not cached (plain in-memory lookups, no I/O) and blocked_attempts_total has its own separate cache, see "Blocked-attempt counter" below.

If your integration was reading the raw data array from getData, there is no drop-in replacement — it was removed entirely rather than turned into a paginated sample, since no known consumer needed row-level detail from this specific action (row-level detail is what getPages/ getVisitorsByIp/getUserMonitors are for). See CHANGELOG [0.10.0] for the full rationale.

Remember-me (returning visitor recognition)

When MonitorMethod creates a new Monitor record for a first-time visitor (session-based, non-bot request), it generates a random token, stores it in the monitors.id_token column (unique index; until 0.41.0 it lived in data['id-token'] and was looked up by scanning the JSON of every row), and attaches it to the response as a long-duration cookie so the same browser can be recognized again after its PHP session expires.

⚠️ Breaking change in v0.2.0: the public GET /monitor/remember-me route was removed — the package no longer opens an HTTP route for this without the host app's explicit awareness. Use Monitor::recognize() below instead (server-side, no HTTP round-trip). See CHANGELOG for the migration note.

Contract for the host application:

  • Cookie name: config('monitor.remember_cookie'), default monitor_id_token. Duration: config('monitor.remember_cookie_days') days, default 1825 (5 years). The package sets this cookie itself — the host app never needs to create or read it directly (it isn't meant to be parsed from document.cookie; the browser just carries it back automatically on every request).

  • Automatic reconnection (since v0.1.22): MonitorMethod also checks the monitor_id_token cookie directly, on the very first request of a new PHP session — before anything else has had a chance to run. This closes a race present in earlier versions, where the first page load of a new session always created a brand-new Monitor (overwriting the cookie with a fresh token) before anything below could ever run, permanently losing the original visitor's identity. Host apps don't need to do anything for this — it's transparent — but it means the cookie alone is now sufficient; Monitor::recognize() is a belt-and-suspenders option, not a requirement, for host apps that want to force reconnection at a specific point (e.g. right after consuming a cookie-consent flow that may have delayed the first tracked request).

  • Monitor::recognize(): bool. Call it server-side, from within the same request that should pick up the returning visitor — it reads the cookie above off the current request (request()->cookie(...), no HTTP call involved) and looks up the matching Monitor row. Returns true when a matching visitor was found (and merged into the current PHP session), false when there's no cookie yet (first-ever visit) or no matching record (cookie is stale/invalid).

    use Drcantagalo\LaravelMonitor\Facades\Monitor;
    
    if (Monitor::recognize()) {
        // returning visitor recognized and merged into this session
    }
  • Under the hood, recognize() sets session(['remember_me' => $token]); the MonitorMethod middleware picks that up on the very same request (it runs after your code, on the way back out) and merges the returning visitor into the current PHP session — same mechanism as before, just triggered by a direct method call instead of an HTTP request.

Arbitrary visitor data (segmentation/tags)

Lets the host application attach arbitrary key/value pairs (language, tags, preferences, etc.) to the Monitor record of the current visitor session — a segmentation/tagging base, not a CRM/lead system yet.

⚠️ Breaking change in v0.2.0: the public GET /monitor/update-data route was removed — same rationale as remember-me above. Use Monitor::tag() below instead.

  • Monitor::tag(array $data): bool. Call it server-side. Requires an active monitor session (i.e. MonitorMethod must have already run at least once for this visitor — same precondition as recognize()). Returns true on success; false when there's no active monitor session yet, or when $data is empty.

    use Drcantagalo\LaravelMonitor\Facades\Monitor;
    
    Monitor::tag(['lang' => 'pt', 'tags' => ['newsletter']]);
  • Protected keys: sessions, ips, visits, page, id-token, ua, user_id (Drcantagalo\LaravelMonitor\Support\Monitor::PROTECTED_DATA_KEYS) are silently ignored if present in $data — these are written exclusively by MonitorMethod/Monitor::newVisit, and letting a caller overwrite them would corrupt tracking. Every other key is accepted freely (schema intentionally left open — see below).

  • Design note: the schema is deliberately unconstrained so it can later support linking a visitor to a real lead/contact, an opt-in shared blacklist across sites, or an external IP-reputation feed — none of which this action builds today.

Authenticated user tagging

Ties a Monitor row (device/browser) to the host app's authenticated user, for CRM linkage later ("if this visitor has logged in before, tag their row with that").

  • Contract: tag, not merge. The Monitor data model is 1 row per device/browser, recognized via the remember_cookie (see Remember-me above) — a user logged in on 2 devices already produces 2 rows today, and that's expected. This feature does not change that: it only writes data['user_id'] (Auth::id()) onto the current device's row, alongside ua/ips/page. It never merges or reassigns rows by user_id. If you need "one record per customer" for a CRM view, do that aggregation at read time (Monitor::forUserId($id)->get(), joining the rows yourself — see "Querying by user_id" below) — never a physical merge of the raw rows, which would race under concurrent writes from multiple devices.
  • When it runs: every request tracked by SessionVisitorTracker (session-based visitors) where Auth::check() is true, right after the Monitor row for the current request has already been found/created. Anonymous tracking (AnonymousVisitorTracker, no session — used for the 404/scraper-detection flow above) is unaffected; this is a session-visitor-only feature.
  • Config: track_authenticated_user (default true). Set to false to opt out — e.g. host apps without Auth configured, or that don't want this data for privacy-policy reasons.
  • Protected key: like sessions/ips/visits/page/id-token/ua, user_id is in Drcantagalo\LaravelMonitor\Support\Monitor::PROTECTED_DATA_KEYS — a call to Monitor::tag() (see "Arbitrary visitor data" above) can never overwrite it, so it can't be spoofed onto a row by mistake.
  • Auto-human tagging (since 0.59.0): the first time user_id is recorded for a Monitor (the guest → authenticated transition — not on every subsequent authenticated request, see below), the package also ensures a monitor_labels row with kind=human, source=auth (a new source value, alongside ai/manual), classified_at=now() — someone who authenticated is, by definition, a real person, so there's no reason for that Monitor to keep sitting in the AI triage queue waiting for a manual/AI classification it'll never need. This never overwrites an existing classification: a Monitor already kind=bot (even source=manual, e.g. a known bot account that happens to log in) or already kind=human keeps it as-is — only a Monitor with no kind yet gets tagged. Gated by the same track_authenticated_user config above; disabling it skips this too.
    • Cheap by design: the check only runs on the request where user_id is being written for the first time (nothing in data yet) — once written, data['user_id'] stays set on every later request for that Monitor, so the classification check never runs again for it. No per-request query for an already-tagged visitor.
    • Backfill: Monitors that already had user_id recorded before upgrading to 0.59.0 never go through that first-time transition again, so a migration backfills them once (same rule: only Monitors with no kind yet get kind=human, source=auth).
    • See "Aggregated dashboard totals" above for how this interacts with monitors_by_kind.human_user, and "Daily timeline" below for series.monitors_new.human_user/series.visits.human_user.
  • Reserved user tag (since 0.61.0): the same guest → authenticated transition above also ensures the Monitor's monitor_labels.tags contains user (MonitorLabel::TAG_USER) — independent of, and never gated by, the kind protection described above: a Monitor already manually classified bot that logs in still gains the user tag (the tag only means "this Monitor is tied to a user_id", it says nothing about the bot/human judgment). This tag is entirely managed by the package — setMonitorTags rejects (422) any manual op=add/ op=remove call with tag=user; there's no way to add or remove it by hand. Backfill: same mechanics as the kind=human backfill above — a migration adds the tag to every Monitor that already had user_id before upgrading to 0.61.0 and doesn't have it yet. This is what lets the IP-level listing (getVisitorsByIp/getBlockedIps) distinguish authenticated visitors (at least one Monitor at that IP carries user) from guests (none do) via the existing tag filter — see "IP classification" below.

Querying by user_id (CRM index)

To support CRM lookups ("show me every device row for this customer") without a full table scan, the migrations add a generated column monitors_user_id (extracted from data['user_id'], VIRTUAL, MySQL-only — see caveat below) with an index (monitors_user_id_idx) on the monitors table.

  • Use Monitor::forUserId($id), not Monitor::where('data->user_id', $id). This is not just a style preference: where('data->user_id', $id) compiles to json_unquote(json_extract(\data`, '$."user_id"')), and even though that's the *exact* expression the generated column is defined with, MySQL's optimizer does not match it to the column/index automatically — confirmed via EXPLAIN on real MySQL 8 (type: ALL, full table scan, possible_keys: NULL). Only querying the generated column by name uses the index. Monitor::forUserId($id)(a scope on theMonitormodel) does this for you:where('monitors_user_id', $id)`.
  • Why the scope casts $id to a string: monitors_user_id is VARCHAR. Comparing it against a native PHP int through PDO (e.g. Auth::id(), which is an int) makes MySQL list the index under possible_keys but not actually use it (key: NULL) — an implicit type-conversion cost, also confirmed via EXPLAIN. forUserId() casts to (string) internally so the comparison is always string-vs-string and the index is used (type: ref) regardless of what type you pass in.
  • MySQL-only. The generated column's expression (json_unquote(json_extract(...))) is MySQL syntax. The migration is driver-aware: it only creates the generated column + index when Schema::getConnection()->getDriverName() === 'mysql', and is a no-op on any other driver (sqlite, pgsql) — down() is guarded the same way. data['user_id'] itself is always written regardless of driver, this only affects whether lookups by it are indexed. On a non-MySQL host, Monitor::forUserId($id) automatically falls back to where('data->user_id', $id) (no index, full scan, but correct) instead of erroring on the missing generated column — note this fallback does not cast $id to string like the MySQL path does: SQLite's json_extract returns the JSON value in its native storage type (e.g. an integer for {"user_id": 42}), and '42' (text) never equals 42 (integer) there, so the fallback must compare against the same type $id was passed in as (in practice always an int, from Auth::id()).

User listing (getUsers, getUserMonitors)

Same auth as getData/getPages/getVisitorsByIp (permanent local_token or the ephemeral read token from issueReadToken) — built for a dashboard's CRM view: "who are my authenticated users, and what did each of them do".

  • getUsers: paginated, aggregated listing — one row per user_id seen in data['user_id'] (see "Authenticated user tagging" above; rows without a user_id are excluded). Params: page (default 1), per_page (default 25, max 100). Response: {"success": true, "data": [{"user_id": "42", "visits_count": 7, "last_activity": "2026-08-30T12:00:00+00:00", "name": null, "email": null}, ...], "meta": {"page", "per_page", "total", "last_page"}}, ordered by last_activity descending. Aggregation (COUNT of visits/MAX(monitors.updated_at)) and pagination run in SQL, grouped by the same indexed generated column Monitor::forUserId() uses on MySQL (monitors_user_id) — never the raw data->user_id expression, for the same index-matching reason documented above.
    • visits_count counts monitor_visits rows per user_id (a LEFT JOIN, so a Monitor with no visit recorded yet still counts as 0) instead of counting Monitor rows. A Monitor row is reused across sessions for the same device/browser (reconnected via the remember-me cookie), not created per visit, so counting rows used to always give 1 for a user who only ever visits from the same browser. (0.12.0–0.41.0 summed data.visits from the JSON blob instead, which is no longer written.)
  • getUserMonitors (since 0.48.0, replacing getUserVisits — see "⚠️ Breaking" in CHANGELOG [0.48.0]): given a user_id ({"success": false, "message": "user_id is required"}, 422, if missing/empty), a paginated listing of that user's Monitor rows (devices/browsers), via Monitor::forUserId($id) — the exact same response shape as getIpMonitors below (id, created_at, updated_at, data sanitized, ips, visits_count), sharing its hydrateMonitorRows helper. Params: page (default 1), per_page (default 20, max 100). Ordered by updated_at descending, id descending as a deterministic tiebreaker — same pattern as getIpMonitors. From here, the dashboard navigates user → Monitors → a specific Monitor's full visit history (getMonitorVisits), the same way getIpMonitors already lets it navigate from an IP.
    • user_id is cast to int before calling Monitor::forUserId() — see "Querying by user_id" above for why passing the raw query-string value through would silently return zero rows on any non-MySQL host.
    • Unlike the getUserVisits action it replaces, this does not rebuild data.page/data.ips or attach each row's last 20 visits — it returns the same lean shape as getIpMonitors (ips, visits_count), leaving the full per-Monitor journey to getMonitorVisits on demand. Avoids loading potentially hundreds of visits per row just to list a user's devices.
  • name/email: the package never queries a host app's users table (arbitrary schema, out of scope for a host-agnostic package). They only appear when the host app already called Monitor::tag(['name' => $user->name, 'email' => $user->email]) (see "Arbitrary visitor data" above; name/email are not in PROTECTED_DATA_KEYS) somewhere in its own request lifecycle, e.g. right after login. Without that call, both come back null and the dashboard falls back to displaying the raw user_id.
    • Stable across a user's Monitors (since 0.57.0): tag() only writes name/email onto the Monitor row active at that exact moment (the one handling the login request), while user_id is stamped onto every authenticated request (SessionVisitorTracker). A user who switches device/browser, or whose session is inherited without going through login again, gets additional Monitor rows with user_id but no name/email. So getUsers, and anywhere a listing exposes user_id (getUserMonitors/getIpMonitors/getMonitorQueue below, via the shared hydrateMonitorRows), resolve name and email independently of each other — each is read off the most recently updated Monitor row of that user_id that actually has that field set (not necessarily the same row for both, and not necessarily the row being listed). null only when no Monitor of that user ever had the field. Resolved in MonitorController:: resolveUserContacts() with one batched query per field for the whole page (whereIn over the page's user_ids), never a per-row/per-user lookup.

Cached the same way as getVisitorsByIp/getBlockedIps (Cache::remember, TTL config('monitor.listings_cache_ttl_minutes'), the shared monitor:listings:version counter) — since this data changes on every tracked visit rather than through an explicit admin action, staleness here is bounded by the TTL alone, same as getPages.

Sanitizing the data blob (since 0.46.0)

Every response that exposes a Monitor's data blob (getUserMonitors above, getIpMonitors below) runs it through Support\DataSanitizer::sanitize() first — one shared helper, so the rule lives in exactly one place instead of being duplicated per action. Today it only strips the legacy id-token key: before 0.42.0 (monitors.id_token, its own unique-indexed column) the remember-me cookie's value lived at data['id-token'], and the migration that introduced the column only read that key to backfill the new column — it never deleted it from the blob. A Monitor row created before 0.42.0 can therefore still carry the raw remember-me token (effectively a credential — whoever has it can be recognized as that visitor) inside data forever, unless sanitized on the way out. The real id_token column itself is a separate concern and is handled the ordinary way: every action's select()/toArray() simply never includes that column in the first place.

Ephemeral read token + dedicated CORS (dashboard direct fetch)

The dashboard (monitor.cantagalo.it) can call /monitor/handler?action=getData directly from the end user's browser instead of always proxying through the host application's server. The permanent local_token never leaves the host application's backend — only a short-lived, read-only token does.

  • issueReadToken (Authorization: Bearer <local_token>, same auth as the other admin actions): generates a random token, stores it in cache for config('monitor.read_token_ttl_minutes') minutes (default 15), and returns {"success": true, "token": "...", "expires_at": "..."}.
  • The token returned by issueReadToken is accepted as a bearer only for read-only actions (getData, getPages, getVisitorsByIp, getVisitorPaths, getBlockedIps, getBlockedPaths, getUsers, getUserMonitors, getBlockResults, getIpMonitors, getMonitorVisits, getIpTags, getTableStats, getAccessLog, getMonitorQueue, getMonitorQueueCounts, getConfig, getTimeline — getIpMonitors/getMonitorVisits since 0.46.0, getUserMonitors since 0.48.0, getIpTags since 0.49.0, getTableStats since 0.50.0, getAccessLog since 0.51.0, getMonitorQueue/ getMonitorQueueCounts since 0.53.0, getConfig since 0.54.0, getTimeline since 0.58.0). clearData, previewClearData (since 0.63.0 — read-only, but kept on the same local_token-only footing as clearData/pruneData rather than the ephemeral-read-token list, since it's part of the same admin cleanup flow), pruneData, updateBlockedIps, unblockIp, flagScraperPath, unflagPath, updateRules, and issueReadToken itself always require the permanent local_token — a read token cannot mint another token or do anything beyond reading.
  • CORS: routes under monitor/* carry their own dedicated CORS middleware (MonitorCors) — it does not read or depend on the host application's config/cors.php, since every client site has a different Laravel install. The allowed origin is config('monitor.dashboard_origin'), defaulting to https://monitor.cantagalo.it (no manual configuration required). Preflight OPTIONS requests get a 204 with the CORS headers attached.

404 tracking + scrapper path blocking

MonitorMethod records, per visited path, whether the response was a 404 (monitor_page_hits.not_found, sticky once set; data.not_found[path] until 0.41.0) — aggregated by getPages (see "Paginated page listing" below) into a not_found flag per path, letting a dashboard flag paths that don't actually exist on the monitored site (a common scraper tell: /wp-admin/install.php on a site that isn't WordPress). Since 0.10.0, getData no longer exposes raw Monitor rows at all (see "Aggregated dashboard totals" below) — this per-path detail only ever came from getPages.

Requires a Route::fallback() in the web middleware group to catch genuinely nonexistent paths. MonitorMethod only runs for requests that actually reach a matched route (it's route-group middleware, not global) — a path with no matching route at all never enters the web group and never sees the middleware, so it can't be tracked as 404, on a vanilla Laravel install with no fallback route. This still covers 404s returned by a matched route/controller (e.g. abort(404) for a missing resource) either way. Adding a fallback route to routes/web.php closes the gap for completely unknown paths too — but the closure must return a real 404 HTTP status, not just a view that looks like one:

// Wrong — view() alone responds 200 OK, so MonitorMethod (and every
// crawler/monitoring tool) sees a successful page, not a 404.
Route::fallback(fn () => view('errors.404'));

// Right — the status code is what actually matters here.
Route::fallback(fn () => response()->view('errors.404', [], 404));
// or simply:
Route::fallback(fn () => abort(404));

This is an easy mistake to make and easy to miss in manual testing (the page looks identical either way) — it was found live in more than one host app integrating this package, always with the same root cause: a view(...) call with no explicit status.

Since 0.39.0, this also decides whether a honeypot's first-hit 404 (see "Honeypot hits" below) is actually indistinguishable from a genuine one. MonitorMethod's own abort(404) runs before this fallback is ever reached, so the two responses only end up byte-identical because Laravel's exception handler renders the same thing for any thrown 404 — a resources/views/errors/404.blade.php, if the host app has one (picked automatically for both), or the framework's own default 404 page otherwise. A fallback that returns a body built by hand instead — e.g. Route::fallback(fn () => response('Not found', 404)) — breaks that equivalence: the honeypot 404 (rendered by the exception handler) and the fallback's genuine 404 (the hand-built body) would have different content even though both are status 404, reopening the same tell this feature exists to close. Stick to one of the two patterns above.

  • flagScraperPath (Authorization: Bearer <local_token>, same auth as updateBlockedIps/clearData — never accepted with the ephemeral read token): POST /monitor/handler?action=flagScraperPath with {"path": "wp-admin/install.php"} (host-less; a leading / is stripped if present). Two things happen:

    1. The path's row in monitor_paths gets status: 'trap' (updateOrCreate, since 0.20.0 — overwrites a 'safe' status if the path was previously marked safe via markPathSafe, making flagging and marking safe mutually exclusive; see "Path review state" below). From then on, MonitorMethod rejects any request whose path matches, regardless of host — an installation shared by multiple subdomains is protected on all of them at once, since the block check ignores the host prefix that tracked paths carry (host/path, in monitor_page_hits.path). Since 0.39.0, the rejection is 404 (indistinguishable from any other nonexistent route) on the first hit from an IP not yet blocked, and 403 from then on once that IP is itself in monitor_blocked_ips — see "Honeypot hits" below for why.
    2. Every IP already recorded (monitor_visit_ips) against a Monitor that visited that path is blocked in monitor_blocked_ips (source: 'scraper-path'), same mechanism as updateBlockedIps.
    • Response: {"success": true, "path": "...", "blocked_ips": [...]}, or {"success": false, "message": "No path provided"} (422) if path is missing/empty.
    • Guard since 0.21.0: before either step runs, the same Monitor scan used to find IPs to block also checks whether the path already resolved as a real, non-404 page in some host (data.not_found empty/false for a matching key). If so, the call is refused — nothing is written to monitor_paths, no IP is blocked — and the response is {"success": false, "message": "\"login\" also resolves as a live route at \"cantagalo.it/login\" — refusing to flag it as a trap"} (422). Because the block ignores host, flagging a path that's a real route on even one host would 403 real users on every other host that route shares the suffix with. No override in this version.
    • Fixed in 0.20.1: the Monitor table scan for step 2 used to load every row into memory at once (Monitor::all()), same class of bug as getData in 0.10.0 above — confirmed exhausting PHP-FPM's memory_limit in production at ~31.7k rows, returning a bare 500 with no body. Now uses Monitor::cursor() (one row hydrated at a time), same matching logic, no response shape change.
    • Fixed in 0.26.0: Monitor::cursor() still meant the guard and the IP scan cost time proportional to the whole monitors table on every call (same bug class fixed for getPages/getVisitorPaths/ PathsAuditor::audit() in 0.23.0-0.25.0, now on the write side — confirmed timing out in production at ~41k Monitor rows). Both the live-route guard and the IP lookup now read from monitor_page_hits (0.23.0): the guard is a suffix-index lookup against WHERE not_found = false (same technique as PathsAuditor::liveTrafficSuffixIndex()), and the IP scan resolves the small set of matching monitor_page_hits rows (WHERE not_found = true) first, then reads monitor_visit_ips only for those specific monitor_ids (until 0.41.0 this decoded Monitor.data.ips). Same matching logic, no response shape change.
  • flagScraperPaths (same auth as flagScraperPath, since 0.19.0): batch version — POST /monitor/handler?action=flagScraperPaths with {"paths": ["wp-admin/install.php", ".env", ...]}. Same effect as calling flagScraperPath once per path (each path blocked, every IP that visited any of them blocked too), but as a single request instead of one HTTP call per path — avoids bursting the caller's own client with N requests when flagging dozens/hundreds of paths at once (e.g. a "select many, flag as trap" bulk action, or an automated triage run). Also more efficient server-side: the Monitor table scan needed to find IPs that visited the flagged paths happens once for the whole batch, not once per path. Invalid entries (non-string, empty after trimming) are silently skipped rather than failing the whole batch. Response: {"success": true, "paths": [...only the ones actually flagged...], "rejected": [{"path": "...", "reason": "..."}, ...], "blocked_ips": [...]}, or {"success": false, "message": "No paths provided"} / "No valid paths provided" (422) if paths is missing/empty or every entry was invalid.

    • Guard since 0.21.0: same live-route check as flagScraperPath, applied per path — a path that resolves as a real route on some host is rejected and reported under rejected (with why), without aborting the rest of the batch. rejected is always present (empty array when nothing was refused).
    • Fixed in 0.26.0: same fix as flagScraperPath above, but it matters even more here — the whole-table scan used to happen once per batch (still O(monitors rows), not O(paths in the batch)), so a large batch (e.g. TriageMonitorPathsJob) made the fixed per-call cost of the old scan land on every triage run. Same resolveScraperPathTargets() helper as the singular version, called once for the whole batch.
  • unflagPath (same auth as flagScraperPath): reverts it — POST /monitor/handler?action=unflagPath with {"path": "wp-admin/install.php"} removes the path's status: 'trap' row from monitor_paths and clears the corresponding MonitorMethod::isPathBlocked() cache entry immediately. Response: {"success": true, "path": "...", "was_flagged": true|false} (false when the path wasn't flagged to begin with — not an error). Does not unblock the IPs flagScraperPath may have blocked because of that path — use unblockIp for those individually.

  • markPathSafe (same auth as flagScraperPath, since 0.4.0): POST /monitor/handler?action=markPathSafe with {"path": "old-campaign-link"} (host-less, same convention as flagScraperPath) records the path in monitor_paths with status: 'safe' and reviewed_at: now() (updateOrCreate, since 0.20.0 — overwrites a 'trap' status if the path was previously flagged via flagScraperPath, making the two mutually exclusive). Purely a review-state flag — unlike flagScraperPath, it blocks nothing; it just removes the path from getPages' pending_review queue (see below) once a human has confirmed a recurring 404 isn't a scraper probe (e.g. an old link that was removed on purpose). Response: {"success": true, "path": "...", "status": "safe"}, or {"success": false, "message": "No path provided"} (422) if path is missing/empty.

    • Guard since 0.21.0: only writes status: 'safe' when at least one hit with data.not_found = true exists for that path in some host — otherwise the review protects nothing (it's orphaned data from the start). Rejected with {"success": false, "message": "\"...\" has no recorded 404 — marking it safe would not protect anything"} (422) when there's no such evidence. No override in this version.
    • Fixed in 0.26.0: the guard used to scan and JSON-decode Monitor.data.not_found row by row (Monitor::cursor()) — same bug class fixed for getPages/getVisitorPaths/PathsAuditor::audit() in 0.23.0-0.25.0, now on the write side. Now a suffix-index lookup against monitor_page_hits WHERE not_found = true (same technique as PathsAuditor::liveTrafficSuffixIndex()), no Monitor scan at all. Same guard behavior, no response shape change.
  • markPathsSafe (same auth as flagScraperPath, since 0.19.0): batch version of markPathSafe — POST /monitor/handler?action=markPathsSafe with {"paths": ["old-campaign-link", ...]}. Same rationale as flagScraperPaths: avoids one HTTP request per path when clearing many entries from the pending_review queue at once. No blocking side effect, same as the singular version. Response: {"success": true, "paths": [...only the ones actually marked...], "rejected": [{"path": "...", "reason": "..."}, ...]}, or {"success": false, "message": "No paths provided"} / "No valid paths provided" (422).

    • Guard since 0.21.0: same 404-evidence check as markPathSafe, applied per path — a path with no recorded 404 anywhere is rejected and reported under rejected, without aborting the rest of the batch.
    • Fixed in 0.26.0: same fix as markPathSafe above — the per-batch Monitor scan (still O(monitors rows) even though it checked every path in the batch in one pass) is now a single suffix- index lookup against monitor_page_hits, built once for the whole batch.
  • unmarkPathSafe (same auth): reverts it — POST /monitor/handler?action=unmarkPathSafe with {"path": "old-campaign-link"} deletes the path's status: 'safe' row from monitor_paths, so the path goes back to the default pending state. Response: {"success": true, "path": "...", "was_safe": true|false} (false when the path wasn't marked safe to begin with — not an error).

Path review state (monitor_paths): since 0.20.0, a path's trap/safe status lives in a single table, one row per path (no row = pending, the default). Before 0.20.0 these were two independent tables (monitor_blocked_paths for trap, monitor_path_reviews for safe) with no relationship between them — a path could end up marked both at once, a contradictory state the old code didn't catch (seen live in production). flagScraperPath(s)/markPathSafe(s) now write to the same row (updateOrCreate), so flagging a path clears a prior safe status and vice versa. See CHANGELOG [0.20.0] for the migration that merges the old tables (existing data included; a conflicting path is resolved to trap, logged via Log::warning during the migration).

Manual IP blocking (updateBlockedIps)

Blocks a list of IPs outright — MonitorMethod rejects (403) any request from a blocked IP before any tracking/detection logic runs, regardless of host or session state. This is the same underlying mechanism flagScraperPath uses automatically for IPs seen on a flagged path; updateBlockedIps is the manual/direct version, for blocking IPs that weren't (or don't need to be) tied to a specific path.

  • Endpoint: POST /monitor/handler?action=updateBlockedIps, Authorization: Bearer <local_token> (the permanent admin token — same auth as clearData/flagScraperPath/updateRules/issueReadToken; the ephemeral read token from issueReadToken is never accepted here).

  • Request body: {"ips": ["203.0.113.7", "198.51.100.42"]}. Each entry is validated with filter_var(..., FILTER_VALIDATE_IP) (accepts both IPv4 and IPv6); invalid entries are silently skipped rather than failing the whole request.

  • Response: {"success": true, "blocked": ["203.0.113.7", "198.51.100.42"]} listing only the IPs that actually validated and got (or already were) blocked. {"success": false, "message": "No IPs provided"} (422) when ips is missing/empty/not an array; {"success": false, "message": "No valid IPs provided"} (422) when every entry failed validation.

  • Persisted in monitor_blocked_ips with source: 'manual' (vs. source: 'scraper-path'/'auto-signal'/etc. for IPs blocked automatically) — same table, same mechanism (ScraperBlocker::registerOffense, see below), so every source composes into one reputation history per IP. The per-IP block-check cache (config('monitor.blocked_ip_cache_ttl'), default 60s) is invalidated immediately for every IP in the request, so the block takes effect on the very next request instead of waiting out the cache TTL.

  • unblockIp (same auth as updateBlockedIps): reverts it (and any automatic block on that IP) — POST /monitor/handler?action=unblockIp with {"ip": "203.0.113.7"} removes the IP from monitor_blocked_ips (whatever its source) and clears the block-check cache immediately. Response: {"success": true, "ip": "...", "was_blocked": true|false} (false when the IP wasn't blocked to begin with — not an error), or {"success": false, "message": "No valid IP provided"} (422) if ip is missing/invalid. Unlike updateBlockedIps, unblockIp does not go through ScraperBlocker — it deletes the monitor_blocked_ips row outright, resetting strike_count/lifetime_offense_count completely. A manual unblock normally means "this should never have been blocked" (an internal test IP, a false positive), so resetting the history is the right behavior — different from letting a temporary block expire on its own.

Since 0.29.0: updateBlockedIps goes through the same escalating mechanism as automatic blocking (see below) instead of creating a permanent BlockedIp directly — see "Temporary, escalating IP blocking" for what that means for a manually blocked IP (it now expires and can become permanent through repeated offenses, same as an automatically blocked one). unblockIp is unchanged (see above).

Temporary, escalating IP blocking (ScraperBlocker)

Since 0.15.0, monitor_blocked_ips supports temporary, escalating blocks (ScraperBlocker::registerOffense(string $ip, string $source)), modeled after fail2ban/CrowdSec rather than a static blocklist. Originally only the automatic path used this (honeypot hits, scraper-signal thresholds — see the changelog entries for the versions that wire each trigger up); since 0.29.0, manual blocking via updateBlockedIps uses the exact same mechanism (source: 'manual') — unifying manual and automatic reputation into a single escalation ladder: recidivism from either source (an IP re-blocked manually after a previous block expired, or re-flagged automatically) accumulates toward the same lifetime_offense_count, eventually becoming permanent (auto_block_permanent_after_lifetime_offenses, default 10) regardless of which path triggered each individual offense. unblockIp stays outside this ladder — see above.

Why temporary: IPs get reused over time (CGNAT, dynamic residential IPs, elastic cloud IPs), so a permanent block from one bad actor can end up punishing an unrelated legitimate visitor who inherits that IP months later. Escalating, self-expiring blocks make automatic blocking safe enough to not require a human reviewing every flagged IP first.

Two separate counters drive this, on purpose — see "Why two counters" below for the bug this avoids:

  • strike_count: decays over time, drives only how long this block lasts.

  • lifetime_offense_count: never decays, only ever increments, drives the permanent-promotion threshold.

  • First offense: strike_count/lifetime_offense_count both start at 1, block lasts 2^1 = 2 hours.

  • Each subsequent offense — an offense committed after the previous block has expired (the IP came back and reoffended) — doubles the exponent on strike_count: 2nd offense → 2^2 = 4h, 3rd → 2^3 = 8h, 4th → 2^4 = 16h, and so on. lifetime_offense_count increments by 1 each time.

  • One attack is one offense (since 0.44.0): while an IP's block is in force (permanent, or temporary and not yet expired), a new offense counts for nothing — strike_count, lifetime_offense_count, blocked_until, last_offense_at and source stay exactly as they are. The counters exist so an IP that "regenerated" can come back later and, if it reoffends after each block, be blocked for longer and eventually permanently; how many infractions it committed inside a single attack is irrelevant. Before 0.44.0, flagging many honeypot paths at once (flagScraperPaths, e.g. the AI triage marking 125 paths) registered one offense per path for every IP that had hit them: 125 strikes and a permanent block from the first batch. flagScraperPaths now also registers a single offense per IP for the whole batch.

  • Decay: every config('monitor.auto_block_strike_decay_cooldown_days') (default 30) days that pass without a new offense from that IP, strike_count drops by 1 (never below 1) the next time that IP offends again — so an IP that goes quiet is treated as less of a repeat offender for how long the next block lasts. lifetime_offense_count is never touched by decay.

  • Permanent promotion: once lifetime_offense_count reaches config('monitor.auto_block_permanent_after_lifetime_offenses') (default 10), blocked_until is set to null (permanent) instead of a new expiry — regardless of what strike_count currently is.

  • source is overwritten with whatever triggered the latest offense (the most recent trigger is the relevant one for that column).

Why two counters, not one: an earlier version of this feature used a single counter for both decay and the permanent threshold. With linear decay (−1 strike per full cooldown period, floored at 1), any IP that reoffends at an interval ≥ the cooldown always gets decayed back to the floor before the new offense is added — once that counter reaches 2, it's stuck there forever (2 − 1 = 1, +1 = 2, repeat), no matter how many times the IP reoffends over months or years. A patient attacker who simply waits out the cooldown between attacks would never reach the permanent threshold. Splitting the counters fixes this: strike_count still decays and correctly reflects "how hot is this IP right now" for sizing the current block, while lifetime_offense_count is a simple, un-decaying tally of "how many times has this IP ever offended" that guarantees even a well-paced repeat offender eventually crosses the permanent threshold — it just takes longer, proportional to how patient they are, which is the correct tradeoff (monthly reoffending for two years is objectively less severe than daily reoffending, but should still end in a permanent block if it never stops).

MonitorMethod::isBlocked() treats a blocked_until in the past as not blocked — the existing blocked_ip_cache_ttl cache (default 60s) already guarantees an expired block disappears from the application within that window, no separate job/cron needed.

Fail-safe against cache store outages (since 0.30.0): isBlocked()/isPathBlocked() catch \Throwable around the Cache::remember call, separately from the existing catch (QueryException) (table not migrated yet, unchanged, still assumes false). If the cache store itself is unavailable (Redis/Memcached down, etc), it does not assume false — that would let a blocked IP/path through for the whole outage window — it runs the same query directly against the database instead, bypassing the cache, and logs a warning. Scoped to just these two methods, since they run on every request to the protected site; the dashboard-only caches (getPages, getVisitorsByIp/listings, block_results) don't need this — a temporary dashboard error is a much smaller cost than letting a scraper through.

Clears monitor_ip_stats.flagged on block (since 0.26.1): right after saving the block, registerOffense() also sets monitor_ip_stats.flagged = false for that IP and invalidates the dashboard listings cache (same mechanism as the manual block/unblock/flag actions). Without this, an IP that gets auto-blocked never goes through the trackers again to recalculate flagged (MonitorMethod rejects the request with 403 before tracking runs), so it stayed shown as "possible scraper" forever even after being blocked. flagged_signals is left untouched — it's the historical record of what tripped the block, not a live status.

Automatic triggers (since 0.16.0)

Two triggers call ScraperBlocker::registerOffense() automatically — no human reviewing the flagged-IP queue required:

  • Scraper-signal threshold: SessionVisitorTracker/ AnonymousVisitorTracker already run ScraperSignalDetector on every tracked request to decide data.flags.scraper. When the number of signals triggered on a single request reaches config('monitor.auto_block_signal_threshold') (default 3, higher than scraper_signal_threshold's default of 2 on purpose — auto-blocking acts without a human, so it deserves more confidence than a flag meant for review), that IP gets an offense registered. Both trackers wire this up, not just the session one — real scrapers typically don't carry a session, so covering only SessionVisitorTracker would miss the common case.
  • Honeypot hits: any IP seen hitting a path flagged as a honeypot registers an offense immediately — a single hit is enough, no signal count needed, since a path nobody legitimate would ever request is already the highest-confidence signal available. This holds both at the moment a path is flagged (flagScraperPath/flagScraperPaths, sweeping every IP that already visited it) and for any hit that happens afterwards (MonitorMethod, since 0.37.0): the first request from an IP that isn't already blocked registers the offense and blocks it before the request is denied; once blocked, later hits from the same IP just take the same rejection like any other blocked IP — one offense per block cycle, not one per request, so a bot hammering the honeypot doesn't escalate to a permanent block by itself. Before 0.16.0 this called BlockedIp::firstOrCreate() directly (permanent, static block from the first hit); it now goes through the same escalating/expiring mechanism as every other automatic block.
    • 404, not 403, on that first hit (since 0.39.0): a 403 response is a tell — it lets a scanner tell the monitored honeypot path apart from every other nonexistent path, which returns 404, turning the very defense meant to be invisible into a trivial oracle for finding it. The rejection is now abort(404) on the request that registers the offense (the offense itself, and the monitor_block_results counter below, still happen exactly as before — only the HTTP status changes), and stays 403 from the second hit onward, once the IP is itself in monitor_blocked_ips — same as any other already-blocked IP hitting any other path. This keeps the CGNAT false-positive diagnostic intact (a shared IP that gets blocked still shows 403 on every subsequent request, so it's easy to spot in logs) while closing the 404-vs-403 tell on the first hit.

Curating which paths count as a honeypot stays 100% manual (a human still decides which routes nobody legitimate would ever hit) — only what happens when one is hit follows the escalation system above. Two things keep a honeypot path from tripping up legitimate traffic: list it as Disallow in robots.txt (well-behaved bots won't request it) and never link it anywhere in your own HTML (avoids prefetchers/link checkers triggering a false positive). A shared IP behind CGNAT still pays the first strike's 2h block if it does get hit — that's an accepted cost of the honeypot being cheap to operate.

Periodic cleanup (since 0.17.0)

monitor_blocked_ips rows never mattered forever — once a temporary block expires and its decay cooldown passes, the row has nothing left to contribute (ScraperBlocker::registerOffense() only reads it if that IP offends again). Support/BlockedIpCleaner::maybeCleanup() deletes those rows periodically, with no cron of your own to configure: it runs the same way Laravel's own session garbage collection does (Illuminate\Session\Middleware\StartSession::collectGarbage()), except on a deterministic cached timestamp instead of a probability — called from both trackers on every tracked request, it only actually sweeps once config('monitor.blocked_ips_cleanup_interval_hours') (default 1) has passed since the last sweep. On a low-traffic site this means cleanup runs on the first visit after the interval elapses, not on the clock exactly — the same limitation Laravel's session GC has, not a regression.

A row is only deleted when all of these hold:

  • blocked_until is not null (a permanent block is never touched).
  • blocked_until is already in the past (expired).
  • config('monitor.auto_block_strike_decay_cooldown_days') has already passed since last_offense_at (the decay window is over too, not just the block itself).
  • lifetime_offense_count === 1.

That last condition protects the fix described in "Why two counters, not one" above: deleting a row with lifetime_offense_count >= 2 would erase its repeat-offense history, letting a patient attacker who paces reoffenses get a free reset every cleanup cycle — the same plateau hole, through a different door. Only an IP that offended exactly once in its lifetime and never came back is safe to forget; that should be the vast majority of isolated flags (most never reoffend), so the table stays small in practice even while permanently keeping every row with 2+ offenses.

Blocked-attempt counter (monitor_block_results)

Since 0.9.0, every request rejected by MonitorMethod (both branches: the IP itself is in monitor_blocked_ips, or the path it hit has status: 'trap' in monitor_paths — including a brand-new IP that was never separately blocked, hitting an already-flagged honeypot path) increments a per-IP counter in the new monitor_block_results table (ip unique, counter, last_attempt_at) — regardless of whether the response was 403 or, since 0.39.0, the 404 a first-time honeypot hit now gets (see "Honeypot hits" above); the counter tracks blocked attempts, not any particular status code. This is a raw "how many times has this IP been turned away" tally, independent of monitor_ip_stats (which only tracks requests that were actually let through/tracked).

  • Atomic upsert: the increment is a single DB::table('monitor_block_results')->upsert(...) call (Laravel's query builder — portable across MySQL/SQLite, generating ON DUPLICATE KEY UPDATE/ON CONFLICT as appropriate for the active driver), not a firstOrCreate + increment pair — the latter is two round-trips and races when concurrent requests from the same IP hit the same blocked endpoint at once (a common shape for a bot hammering a honeypot path), potentially under-counting.
  • Fail-open: wrapped in the same try/catch (QueryException) pattern as the rest of MonitorMethod — if monitor_block_results hasn't been migrated yet in some environment, the increment is skipped (logged via Log::warning) and the request is still blocked (abort() runs unconditionally, outside the try/catch, with the 403/404 status decided as described above).
  • blocked_attempts_total: a new field on the existing getData response (SUM(counter) across every row) — reuses the same client-side fetch that already powers the dashboard's KPI cards instead of adding a dedicated endpoint for one number.
  • getBlockResults: new paginated, read-token-eligible action (same auth as getVisitorsByIp/getBlockedIps — permanent local_token or the ephemeral read token) — GET /monitor/handler?action=getBlockResults, params page (default 1), per_page (default 20, max 100). Response: {"success": true, "data": [{"ip": "203.0.113.7", "counter": 42}, ...], "meta": {"page", "per_page", "total", "last_page"}}, ordered by counter descending (most-blocked IPs first). If the table isn't migrated yet, returns an empty page instead of erroring (same fail-open principle as above).
  • Caching: both blocked_attempts_total and getBlockResults use a short, fixed TTL (config('monitor.block_results_cache_ttl_seconds'), default 45 seconds) — deliberately not the versioned cache scheme shared by getPages/getVisitorsByIp/etc (invalidatePagesCache/invalidateListingsCache). That scheme assumes rare mutation (a manual admin action bumps the version once); this counter can increment on every single request from a hammering bot, and bumping a shared cache version that often would thrash the cache for every other unrelated listing on the dashboard.

See CHANGELOG [0.9.0] for the full rationale.

Web-server deny-list export (monitor:export-denylist)

Generates a deny-list snippet from monitor_blocked_ips, for blocking IPs at the web-server level (Apache/Nginx) instead of/in addition to the application-level block in MonitorMethod. Useful once the blocked-IP list grows large enough that rejecting requests before they even reach PHP is worth it.

php artisan monitor:export-denylist --format=apache
php artisan monitor:export-denylist --format=nginx
  • --format: apache or nginx. If omitted, falls back to config('monitor.denylist_format') (default apache).
  • Output path: config('monitor.denylist_path') (default storage_path('app/monitor/denylist.conf')), directory created automatically if it doesn't exist yet.
  • Apache format: one Require not ip x.x.x.x per line — meant to be Included from the vhost config. Apache re-reads included files automatically, no reload needed.
  • Nginx format: one deny x.x.x.x; per line. Nginx does not pick up config changes on its own — you (or your own cron/deploy hook) need to run nginx -s reload after the file changes. The package deliberately does not attempt to trigger this itself (the web app process isn't the right place to reload the web server).

Exporting: the package never regenerates this file on its own — you decide when. Three ways to trigger it:

  • Run php artisan monitor:export-denylist by hand, whenever you want.

  • exportDenylist (Authorization: Bearer <local_token>, same auth as updateBlockedIps/clearData — never accepted with the ephemeral read token, since it writes to disk on the host server): POST /monitor/handler?action=exportDenylist, no body needed. Regenerates the file using config('monitor.denylist_format') and returns {"success": true, "path": "..."} (or {"success": false, "message": "..."} with a 500 if the write fails, e.g. permissions). This is what a dashboard/UI button ("Export denylist now") calls.

  • Schedule your own cron on the host server, e.g. to re-export once a day right before Apache/Nginx would otherwise pick up a stale file:

    0 3 * * * cd /path/to/your/app && php artisan monitor:export-denylist >> /dev/null 2>&1
    

There used to be a denylist_auto_export config flag that regenerated the file automatically on every updateBlockedIps/unblockIp/ flagScraperPath call — removed (see CHANGELOG) because it rewrites the entire file on every single call, which scales badly when reviewing a large queue of flagged IPs one by one, for a freshness guarantee most consumers didn't need faster than their own web server reloads config anyway.

denylist_export_interval_hours (default 24): since 0.15.0, temporary blocks (see "Temporary, escalating IP blocking" above) are only included in the generated file if the time remaining until blocked_until is at least this many hours — a block expiring sooner than your next scheduled export would never get its Require not ip/deny line removed in time, leaving the web server blocking an IP the application has already released. Set this to match the actual frequency of whatever cron you configure to run monitor:export-denylist/exportDenylist (e.g. 24 for the daily example above). Permanent blocks (blocked_until = null) are always included, no matter this setting.

Auditing reviewed paths (monitor:audit-paths)

Manual, on-demand audit (never automatic) of every monitor_paths row (trap and safe) against the consuming application's real routes and live traffic — catches a path reviewed the wrong way even when the guard described above (since 0.21.0) had nothing to check against yet, because the colliding route was never actually visited (no Monitor traffic exists for it at all).

php artisan monitor:audit-paths

Prints a table of findings (empty table + a confirmation message if nothing collides) and exits 0 either way — this command reports, it never fixes. Each finding: the path, its current status (trap/safe), the matched route (uri + source: route_table or traffic), and a severity (high for a colliding trap — risk of blocking a real user — info for a colliding safe, which only means the review was unnecessary: a path that's genuinely a live route was never a threat to begin with).

Two checks, unioned (a row can match either, or both — route_table wins when both match, since it's the more precise signal):

  • route_table: the path is tested against every registered route's compiled regex (Route::getRoutes()) — dynamic parameters (users/ {id}, custom where() constraints, etc.) resolve correctly since this reuses Symfony's own route compiler instead of reimplementing parsing. Route domain is deliberately ignored (mirrors isPathBlocked()'s host-agnostic surface: "this route exists on whatever domain the installation serves"). Fallback routes (Route::fallback(...), commonly registered so honeypot paths reach MonitorMethod at all — see the routes/web.php note in this README) are excluded on purpose: their regex matches literally any path, which would make this check always positive and useless.
  • traffic: same live-route criterion as the 0.21.0 guard — data.not_found empty/false for some Monitor hit whose key matches the path. Catches a resource that never goes through Laravel's router (a static file, etc.) that route_table alone wouldn't see. Since 0.25.0, this reads from monitor_page_hits (0.23.0) via one SQL query plus an O(1) per-suffix set lookup, instead of decoding Monitor.data for every row — see the monitor_page_hits note under Paginated page listing (getPages) below; before 0.25.0 this scanned the entire Monitor table in PHP on every call and could exceed a typical 10s HTTP timeout on installations with tens of thousands of Monitor rows (confirmed in production).

Synchronous, no job/queue involved — it's regex matching in memory against the route table plus SQL-backed lookups against monitor_page_hits, order of seconds even against thousands of monitor_paths rows (confirmed: production has ~4,900 today, well under a second). No MonitorAiTriageRun-style async/polling needed — that command is async because of external AI API latency, not data volume.

  • auditPaths (Authorization: Bearer <local_token>, same auth as flagScraperPath/clearData — never accepted with the ephemeral read token): POST /monitor/handler?action=auditPaths, no body needed. Response: {"success": true, "findings": [{"path": "...", "status": "trap"|"safe", "matched_route": {"uri": "...", "source": "route_table"|"traffic"}, "severity": "high"|"info"}, ...]} (empty array when nothing collides).

Undoing a finding is still manual — this command/action never calls unflagPath/unmarkPathSafe on its own, by design; whoever reviews the report decides.

Scraper signal detection

Every tracked request — with or without an active session — is scored against a small set of heuristics before being recorded, via the shared ScraperSignalDetector. This is detection only — it marks the Monitor record, it never blocks anything by itself (blocking is flagScraperPath/updateBlockedIps, both actions your own dashboard/automation can trigger after inspecting these flags).

Signals checked by ScraperSignalDetector::detect:

  • high_frequency: more than config('monitor.scraper_frequency_threshold') requests (default 5) from the same IP within config('monitor.scraper_frequency_window_seconds') seconds (default 10).
  • empty_user_agent: the request has no User-Agent header at all.
  • known_bot_user_agent: the User-Agent contains (case-insensitive) any substring from config('monitor.scraper_known_bot_user_agents') — ships with a default list covering common crawlers/bots/HTTP clients (bot, spider, curl, python-requests, headlesschrome, ahrefsbot, etc.); override via config publish to extend or replace it.
  • missing_browser_headers: at least 2 of Accept, Accept-Language, Accept-Encoding are absent — real browsers always send all three, most scripted HTTP clients don't set any of them by default.

Removed in 0.74.0: high_cumulative_visits (IP's total visit count, monitor_ip_stats.visit_count, reaching config('monitor.scraper_cumulative_visits_threshold'), default 5000) was dropped — it false-positived on legitimate high-volume traffic (e.g. a per-token/per-IP integration that legitimately accumulates thousands of calls) with no reliable way to tell that apart from a "patient" scraper by IP volume alone.

Every signal that fires is appended to data.flags.scraper_signals (array of strings, e.g. ["empty_user_agent", "missing_browser_headers"]) on that visitor's Monitor record. data.flags.scraper is true once the number of signals that fired reaches config('monitor.scraper_signal_threshold') (default 2) — a single weak signal (e.g. just a missing Accept-Language) isn't enough on its own, avoiding false positives from unusual-but-legitimate clients.

Per-IP stats (monitor_ip_stats)

Every tracked request also upserts a row in monitor_ip_stats — one row per unique IP, keyed on ip, via IpStat::recordVisit() — as a lightweight index for listing/paginating/filtering visitors by IP without scanning every Monitor.data.ips JSON array (that scan doesn't paginate or filter well at any real volume). Columns: visit_count (incremented on every tracked request from that IP), first_seen/last_seen (timestamps), and flagged/flagged_signals — mirroring the most recent ScraperSignalDetector result for that IP, same semantics as data.flags.scraper on Monitor (reflects the latest request, not an accumulated OR of every request ever seen from that IP).

Since 0.11.0, recordVisit() is a single atomic DB::table('monitor_ip_stats')->upsert(...) call (ON DUPLICATE KEY UPDATE/ON CONFLICT depending on the driver) instead of a where('ip', $ip)->first() followed by create()/save() — the old non-atomic version let two concurrent requests from the same IP both see no existing row and both try to create(), and the second one violated the ip unique constraint and threw an uncaught QueryException (a real 500 for the visitor/bot making the request). first_seen/created_at are only ever written on insert (never touched by the update clause, so they survive every later visit).

Removed in 0.29.0: the table carried a safe column (boolean, a persisted human-reviewed verdict on an IP, set/cleared via markIpSafe/ unmarkIpSafe) between 0.8.0 and 0.28.x. Dropped as a design decision — IP is not a stable enough identity (dynamic pools, CGNAT, recycled IPs) to justify a permanent whitelist based on it alone, unlike monitor_paths' safe status (see "Path review state" above), which is unaffected. There is no replacement action: someone reviewing the flagged queue in getVisitorsByIp and deciding an IP isn't a scraper simply moves on — there's no dedicated "discard" action anymore, just block (via updateBlockedIps) if it is one.

See "Paginated visitor/blocklist listing" below for the read/pagination action on top of this table (getVisitorsByIp).

IP classification (monitor_labels)

Since 0.49.0: an optional bot/human classification + free-form tags + note. This is annotation only — it never affects blocking, scraper detection, or path triage; a wrong label here can only mislabel, never wrongly allow or block anyone.

Breaking in 0.53.0: the classification moved from the IP (monitor_ip_labels, one row per ip) to the Monitor (monitor_labels, one row per Monitor, FK cascadeOnDelete). An IP is an unstable identity (dynamic pools, CGNAT, recycled addresses, residential proxies — the same reasoning behind removing monitor_ip_stats.safe in 0.29.0, see "Per-IP stats" above) — it was never the actual visitor. The classification is a conclusion drawn from a visitor's data (user agent, hit count, distinct paths, time window), so it belongs to the Monitor row that visitor's tracking already lives in, and it should disappear when that Monitor does — including a source=manual label, which used to survive an IP that was never seen again, forever. Upgrading runs a migration that copies every monitor_ip_labels row to every Monitor seen from that ip (via monitor_visit_ips); see "Upgrading to 0.53.0" below for the full mechanics and the conflict-resolution rule.

The IP-level view everywhere it's exposed (getVisitorsByIp, getBlockedIps, filter=clean_bots/clean_humans/clean_unclassified) is now derived on the fly from the Monitor rows seen at that IP — never stored: kind becomes bot if every classified Monitor at that IP is bot, human if every one is human, mixed if there's a mix of both, or null if none of them has a kind. tags is the normalized union of every Monitor's tags at that IP. See "Read actions" below for the exact response shape.

Every Monitor starts undefined by the absence of a monitor_labels row, not a row with null fields — a row with no kind, no tags, and no note is deleted rather than kept around empty (every write action below does this automatically).

⚠️ Breaking in 0.61.0: classification is now writable ONLY on the Monitor, never on the IP. setIpKind, setIpLabels, and setIpTags have been removed (no replacement, no compatibility shim — calling any of them now falls through to the generic 400/"Invalid action" response any unrecognized action gets). Decided with the user (2026-10-05): all three used to write the same kind/tags/note to every Monitor ever seen from a given IP in one call. On a shared IP (NAT/CGNAT, residential proxy, a household behind one router) that meant tagging one person's device could silently relabel a different person's or device's Monitor just because they happened to share that IP at some point — a privacy/data-correctness bug, not a feature. setMonitorKind/ setMonitorTags (below) are now the only write paths for classification, always scoped to one Monitor. The IP-level view (getVisitorsByIp/getBlockedIps/getIpMonitors) remains exactly as useful for reading — still derived on the fly from the Monitors seen at that IP (see below) — it's only writing through the IP that's gone. If your integration used to call setIpKind/setIpLabels/setIpTags, switch to looking up the Monitor(s) at that IP (getIpMonitors) and calling setMonitorKind/setMonitorTags per monitor_id instead.

  • kind: bot, human, or null (undefined). Set via setMonitorKind (one Monitor).
  • tags: a short list of free-form strings, normalized on every write (trimmed, lowercased, deduplicated, capped at 20 tags of 40 characters each — longer/duplicate entries are silently dropped, not rejected). Set via setMonitorTags (add/remove one tag, one Monitor). One tag, user, is reserved — see below.
  • note: an optional free-text note. No write action sets this any more since setIpLabels (the only action that ever touched note) was removed in 0.61.0 — an existing note is preserved (nothing prunes it on its own) but there is currently no HTTP action to set a new one or change it. If your integration needs to edit note per-Monitor, that's tracked as a follow-up (home-page 296); for now, note is effectively read-only/legacy data.
  • source: manual (default), ai (written by the AI triage flow; since 0.61.0 the AI flow has no bulk-by-IP write path either, so it must also classify one Monitor at a time — the ai value is still accepted on setMonitorKind), auth (since 0.59.0, written automatically the first time a Monitor authenticates — see "Authenticated user tagging" above), or verified/verified-{method} (since 0.73.0, written by Monitor::markAsHuman() — see "Marking a visitor as human" below). Refers to kind specifically, not to tags/note. Guaranteed by the package itself, not just the consumer: a source=ai write never overwrites a kind already set by ANY protected source on that same Monitor — manual, auth, or any verified* (MonitorLabel::isProtectedSource(), checked by both setMonitorKind and markAsHuman(); maybeAutoHumanClassify() separately only ever fills in a Monitor with no kind yet, regardless of source). A manual write (setMonitorKind without source=ai) always wins and always records source=manual.
  • classified_at: when kind was last (re)written for that Monitor. Not touched by tag-only writes.

Reserved user tag (since 0.61.0): MonitorLabel::TAG_USER ('user') is added/removed automatically by the package itself, in sync with Monitor.data.user_id (see "Authenticated user tagging" above) — it is never writable by hand. setMonitorTags rejects (422) any manual op=add/op=remove call with tag=user, whether or not the Monitor already has it. This is what lets the IP-level listing distinguish authenticated visitors from guests: an IP where at least one Monitor carries user has seen a logged-in visitor; tag=user on getVisitorsByIp/getBlockedIps filters for exactly that (see "Paginated visitor/blocklist listing" below) — no special-casing needed, it's the same generic tag filter every other tag already uses.

Reserved tag-filter syntax (since 0.72.0): *, -, and any tag starting with - (e.g. -foo) can never be saved as a real tag — MonitorLabel::normalizeTags() drops them silently (same as any other invalid tag), setMonitorTags rejects them with a dedicated 422. They only mean something as the value of the tag filter param on getVisitorsByIp/getMonitorQueue — see "Paginated visitor/blocklist listing" and "getMonitorQueue" below for what they do there.

Never touched by pruneData, the automatic prune (DataPruner), or clearData directly — but since 0.53.0 a label lives on the Monitor row itself (cascadeOnDelete), so it is deleted the moment its Monitor is, by any of those three, including a source=manual label. Before 0.53.0 a label survived its Monitor/IP being pruned indefinitely; that's the one deliberate behavior change this migration makes on purpose (see the intro above).

Write actions (local_token only, same auth as updateBlockedIps)

  • setMonitorKind (since 0.53.0): monitor_id (422 if missing/not an existing Monitor), kind (bot/human/null, 422 if anything else), source (manual default | ai, since 0.62.0 — any other value falls back to manual). This is the Monitor detail view's classification control, and since 0.61.0 the only way to write kind at all. A source=ai write is silently ignored (not applied) if the Monitor already has a kind set with a protected source (manual, auth, or any verified* — see the source field note above and "Marking a visitor as human" below) — a source=manual write always applies, regardless of the Monitor's current source. Response: {"success": true, "monitor_id": 10, "kind": "bot", "applied": true} — applied: false when the write was ignored by that protection (kind in the response is then the Monitor's unchanged current value, not the one submitted).
  • setMonitorTags (since 0.53.0): monitor_id (same validation as setMonitorKind), tag (single string, normalized; 422 if empty after normalization, 422 if it's the reserved user tag — see above — or */-/any -foo, reserved for the tag filter syntax, since 0.72.0), op (add default | remove). Always source=manual semantics don't apply here — tags have no source tracking at all (see the source field note above). Response: {"success": true, "monitor_id": 10, "tag": "amazon", "op": "add"}.

Read actions

  • getIpTags: every tag currently in use across monitor_labels
    • how many Monitors carry it, sorted by count descending — feeds the tag autocomplete in the dashboard. No pagination (the tag vocabulary is small by nature). Same auth as getData (permanent local_token or the ephemeral read token). Response: {"success": true, "data": [{"tag": "amazon", "count": 12}, ...]}.
  • getMonitorQueue (since 0.53.0): started as the AI-triage work queue, replacing the old clean_ai_queue filter of getVisitorsByIp (removed in that same version) — lists Monitors, not IPs, since classification belongs to the Monitor. Params: page/per_page (same as getIpMonitors), group (required, 422 on anything outside new/unclassified/all/clean/clean_bots/clean_humans — including the old recheck, removed in 0.55.0, see below). Since 0.69.0: date_from/date_to (optional, filter by the Monitor's own created_at, same convention as getVisitorsByIp) and tag (optional, filters by monitor_labels.tags, same lowercase/trim normalization as getVisitorsByIp's tag param, including the */-/-foo special values since 0.72.0 — here they apply straight to the Monitor's own tags, not aggregated by IP) — all combinable with any group and with each other, and part of the cache key below.
    • new/unclassified: Monitors with no kind at all (no monitor_labels row, or one with kind null), split only by age: new (created_at newer than ai_triage_min_age_hours, config, default 24 — purely informational, never surfaced for triage) and unclassified (created_at at or past that threshold — the actual manual/AI triage backlog). Both exclude Monitors seen at an IP that's currently flagged (monitor_ip_stats.flagged) or actively blocked (BlockedIp::active()) — those already have their own work queue (filter=flagged/blocked on getVisitorsByIp), no need to also surface them here.
    • all/clean/clean_bots/clean_humans (since 0.61.0, for the dashboard's "Monitors" tab): reuse this same action/method rather than a parallel one, since the paginated response shape is identical.
      • clean: a Monitor is "clean" when it has not been seen at any IP with monitor_ip_stats.flagged=true, nor at any currently-blocked IP (same exclusion set as new/unclassified above) — independent of kind/age, so a clean Monitor can be bot, human, or unclassified. Blocked-IP exclusion here is a safety net only, not elaborate logic: DataPruner already deletes Monitors at a blocked IP in batches, so they only exist briefly either way.
      • clean_bots/clean_humans: clean above, further filtered by monitor_labels.kind = 'bot'/'human'.
      • all: every Monitor, no exclusion at all. Each row gains a read-only, display-only inherited_flagged boolean: true when that Monitor has been seen at a flagged IP (not blocked — a blocked IP's Monitors are already covered by blocked elsewhere and are short-lived by design, see above). Never stored — computed fresh on every call, same as everything else in this response.
    • Response shape is the same enxuto shape as getIpMonitors/ getUserMonitors (via the shared hydrateMonitorRows, each row already carrying its own kind/tags/note/source/ classified_at — see "User listing" above), plus inherited_flagged for group=all. Cached the same way as getVisitorsByIp/ getBlockedIps (see "Paginated visitor/blocklist listing" below).
  • getMonitorQueueCounts (since 0.53.0): {"success": true, "new": 7, "unclassified": 12} — the same two groups as getMonitorQueue, unpaginated counts only, feeding the triage modal in the dashboard. Since 0.61.1: cached under a key that includes the new/unclassified cutoff rounded to the minute (same technique as getMonitorQueue below), so it can never drift from getMonitorQueue(group=unclassified|new) by more than the minute a Monitor ages past the cutoff — previously it cached under a key with no time component at all, so it could return a count up to listings_cache_ttl_minutes stale relative to getMonitorQueue's own (independently cached) answer for the exact same moment, purely from the passage of time (no write involved). Still cache-backed and best-effort within that one-minute window, not a live/atomic count.

See "Paginated visitor/blocklist listing" below for how the derived classification surfaces in getVisitorsByIp/getBlockedIps, and "User listing" above for how the per-Monitor classification surfaces in getIpMonitors/getUserMonitors's response.

Upgrading to 0.53.0

The upgrade migration copies every monitor_ip_labels row to every Monitor ever seen from that ip (via monitor_visit_ips), then drops monitor_ip_labels. If a Monitor would inherit conflicting labels from more than one IP (e.g. two different IPs both saw the same device), source=manual always wins over any other source; between two candidates of the same protection tier (both manual, or neither), the one with the more recent classified_at wins. A monitor_ip_labels row whose IP was never seen by any Monitor (no monitor_visit_ips row at all) is discarded — there's no visitor to inherit it. Run php artisan migrate --force after updating; no other manual step is needed. spreadIpLabel (added in 0.52.0) is removed in this same version — the config it used, ip_spread_max_targets, is gone too — since writing a kind to an IP now already reaches every Monitor at that IP directly (setIpKind), the neighbor-propagation trick it existed for no longer applies.

Upgrading to 0.55.0

Breaking: the recheck group of getMonitorQueue/ getMonitorQueueCounts is gone, replaced by new (a grace period, not a re-check). Reasoning: a Monitor is a device/browser — it doesn't "become" a bot or a human later. recheck (0.53.0) only existed because unclassified accepted a brand-new Monitor (as little as one hit) and the AI would classify it on thin evidence, then recheck tried to patch that by re-looking at Monitors that kept accumulating activity after being classified. The actual fix is not classifying too early in the first place, not reclassifying after the fact.

  • New config ai_triage_min_age_hours (default 24, replaces ai_recheck_min_new_hits): a Monitor with no kind stays in new (informational only, never surfaced for triage) while younger than this many hours, based on created_at — not last activity, since ongoing activity must not be able to postpone triage forever. A Monitor with only 1-2 hits that never comes back still gets classified once the grace period passes, with whatever evidence it has. Once past it, the Monitor moves to unclassified, same as before.
  • recheck_summary (the before/after hit breakdown each recheck row used to carry) is gone with the group — there's nothing to summarize for a grace-period Monitor.
  • Manual classification (setMonitorKind/setMonitorTags/setIpKind etc.) is not affected by the grace period — it has always worked, and keeps working, on any Monitor regardless of age.
  • No migration needed — new/unclassified are both derived on the fly from created_at and the absence of kind, nothing is stored. Run php artisan monitor:update to publish the new config key.

Upgrading to 0.56.0

Breaking: getData no longer returns sessions_total (it has been identical to visits_total since 0.42.0) — read visits_total instead. New key monitors_by_kind ({human, bot, unclassified, new}, sums to visitors_total), see "Aggregated dashboard totals" above. No migration or new config key; ai_triage_min_age_hours now also sets the new/unclassified boundary of monitors_by_kind.

Upgrading to 0.61.0

Breaking: setIpKind, setIpLabels, and setIpTags are removed — no replacement action, no compatibility shim. Calling any of them now gets the generic {"success": false, "message": "Invalid action"} (400) response any unrecognized action gets. See the intro of "IP classification" above for why (IP-level writes leaked classification across people/devices sharing an IP) and for the setMonitorKind/ setMonitorTags replacement path. If your integration polls/writes classification by IP, switch to resolving the Monitor(s) at that IP first (getIpMonitors) and writing per monitor_id.

Run php artisan migrate --force after updating: a backfill migration adds the reserved user tag (see "Authenticated user tagging" above) to every Monitor that already has data.user_id set but doesn't have the tag yet (same mechanics as the 0.59.0 auto-human backfill) — safe to run on a large table, batched, and a no-op if there's nothing to do.

No action needed for the new getMonitorQueue groups (all/clean/ clean_bots/clean_humans) — purely additive, nothing stored or migrated for them.

Visitor origin tagging (since 0.65.0)

When a brand-new Monitor is created, the package detects where that visitor came from and records it as an ordinary custom tag in monitor_labels.tags (the same tags you can add by hand) — not as a classification. Only on creation (first-touch): later visits from the same Monitor never change the tag, and Monitors that already existed before this feature are never retroactively tagged.

Detection order, first match wins, none matching means no tag at all:

  1. utm_source on the entry URL (the market-standard convention, e.g. ?utm_source=linkedin&utm_medium=social). A short alias, lm=, is accepted as a fallback when utm_source is absent (never merged with it).
  2. Known click ID on the entry URL → a fixed source, via config('monitor.origin_click_ids') (extensible): gclid/gbraid/ wbraid → google, fbclid → facebook, msclkid → bing, ttclid → tiktok, li_fat_id → linkedin, twclid → twitter.
  3. Referer domain, via config('monitor.origin_referer_domains') (extensible, wildcards like "google.*" accepted): a mapped domain → its fixed source; a Referer pointing at your own site → ignored, it's not an origin; any other domain → the raw registrable domain (e.g. example.com). The registrable-domain fallback is a simple "last two labels" heuristic with no public-suffix list, so a two-part TLD like .co.uk/.com.br yields just that suffix instead of the full domain — a known, accepted limitation (the worst case is a slightly odd tag, never an error).

What counts as "your own site" (since 0.70.0): the main domain collected by monitor:install/monitor:update (main_domain in storage/monitor/installation.json, see "Updating" above) and any subdomain of it — so a visit on app.example.com with a Referer from example.com, or vice-versa, is correctly recognized as internal navigation either way. Installations that haven't been asked yet (no main_domain key — run monitor:update to migrate) fall back to the current request's host and its subdomains, which only catches same-direction navigation (landing on the same host the Referer points at, or a subdomain of it) — it deliberately never falls back to the "last two labels" shortcut above, which on a .com.br/.co.uk site would silently swallow every Referer under that same composite suffix as "internal", hiding real external traffic.

utm_source/lm is free text coming straight from the visitor: the value is lowercased, restricted to [a-z0-9._-], and run through MonitorLabel::normalizeTags() (length cap) before being stored, and can never produce the reserved user tag (or any other reserved tag) — a value that sanitizes to empty, too long, or reserved falls through to the next method in the order instead of giving up entirely.

A MonitorLabel row created with only the origin tag (no kind, no note) still counts as unclassified everywhere in the package and in whatever the dashboard builds on top of it (getMonitorQueue, getData.monitors_by_kind, the AI triage queue) — classification is decided purely by kind, which this feature never touches. The tag merges normally with anything added by hand later (including the reserved user tag synced by maybeAutoHumanClassify(), if the same request also happens to be a first-time login) — a user can edit or remove it like any other tag.

Toggle with config('monitor.origin_tagging') (default on — no action needed on upgrade, and turning it off never removes a tag already recorded, it only stops recording new ones). Captured at the exact same point a Monitor is created today (SessionVisitorTracker/ AnonymousVisitorTracker), with no extra query on a request for a Monitor that already exists.

https://example.com/landing?utm_source=linkedin&utm_medium=social

Note: links opened from inside the LinkedIn, WhatsApp, or Instagram apps often arrive with no Referer at all (in-app browsers commonly strip it) — step 3 above never fires for them. Put utm_source on any link you publish yourself if you want it reliably tagged; click IDs (step 2) and Referer (step 3) are best-effort fallbacks for traffic you don't control the link for.

Custom conversion tags (since 0.66.0)

Lets the host app record a conversion (signup, sale, or anything else the site owner wants to observe) as an ordinary custom tag in monitor_labels.tags — the same tags you see on the dashboard and can add by hand. Not the same as Monitor::tag(array $data) above: that one writes to Monitor.data (arbitrary key/value, segmentation), this one writes to monitor_labels.tags (the dashboard's tags). Don't confuse the two.

  • Monitor::addTag(string $tag): bool / Monitor::addTags(array $tags): bool — tags the current visitor's Monitor. Use it from the request that completes the conversion itself, e.g. right after a successful signup:

    use Drcantagalo\LaravelMonitor\Facades\Monitor;
    
    Monitor::addTag('registered');

    Unlike Monitor::tag()/recognize(), this one does not require session('monitor_id') to already exist. MonitorMethod only creates/finds the visitor's Monitor in its "return" phase (after the controller runs), so on a brand-new visitor's first request there is no monitor_id in the session yet when the controller calls addTag(). In that case the tag is queued as pending (kept in a request-scoped container binding, never Cache — that would leak the tag across visitors — and never the session, which is exactly what doesn't exist yet) and applied automatically right after the Monitor for this same request is created or found, through the same sanitize/dedupe/MAX_TAGS path described below. addTag()/addTags() still return true in this case (the tag survived sanitization and will land on the Monitor), even though nothing is in the database yet at the moment of the call. On every request after the first, session('monitor_id') is already set and the tag is applied directly as before — nothing changes for a returning visitor.

    On a request where tracking doesn't actually run — the visitor's IP is in monitor.ignore_ips, the route is behind the avoid-monitor middleware, Monitor::skipTracking() was called, the visitor is blocked, or the call happens in a console/Artisan context — nothing ever consumes the pending tag, so it's silently dropped with the rest of that request: no error, no warning log, it just has nowhere to go.

  • Monitor::addTagForUser(mixed $user, string|array $tags): int — for conversions with no visitor session, e.g. a payment webhook or a queued job. Accepts either the host app's user model (anything with a getKey() method) or a raw user id, and applies the tag(s) to every Monitor linked to that user (the same data.user_id link the reserved user tag relies on — see "Authenticated user tagging" below). Returns how many Monitors got tagged (0 if the user has none, or no tag survived sanitization):

    Monitor::addTagForUser($order->user_id, 'buyer');

    Safe to call from more than one code path for the same conversion (e.g. both TokenPaymentService::captureOrder() and the PAYMENT.CAPTURE.COMPLETED webhook, whichever runs first or both) — idempotent, the tag is never duplicated.

  • Sanitization: each tag is lowercased, restricted to [a-z0-9._-] (same restriction as visitor-origin tags above), capped at MonitorLabel::MAX_TAG_LENGTH, and run through MonitorLabel::normalizeTags() against the Monitor's existing tags (dedupe + MonitorLabel::MAX_TAGS). A tag that doesn't survive sanitization, that doesn't fit under MAX_TAGS, or that collides with the reserved user tag (or any other reserved tag) is silently dropped with a log warning — never an error.

  • Never throws: a database or session failure logs a warning and returns false/0 — a broken monitor call can never break the site's signup/checkout flow, by design (same contract as Monitor::tag()).

  • A MonitorLabel row created with only a conversion tag (no kind, no note) is still unclassified, same rule as visitor-origin tags above — classification is decided purely by kind, untouched here.

  • No built-in timestamp/event log: reconstruct when a conversion happened from the Monitor's own visits, or from the host app's own data (e.g. the purchase row) — deliberately out of scope here.

Marking a visitor as human (Monitor::markAsHuman, since 0.73.0)

Lets the host app tell the package "this visitor just proved they're human" — server-side, right after the host app itself verified some proof (a Turnstile/reCAPTCHA challenge, the classic "I'm not a robot" checkbox, a scroll/interaction test, whatever the site already uses). Producing that proof is entirely the host app's own responsibility — this package has no opinion on it and does not verify anything itself. markAsHuman() only records the outcome in monitor_labels for the current visitor's Monitor (same session('monitor_id') precondition as tag()/ recognize() above — call it from a normal request, not a queued job).

Security warning — read before wiring this up. Never expose a route or endpoint that calls Monitor::markAsHuman() directly, callable from the browser, without first verifying the proof server-side. If you do, any visitor — including a real bot — can call that endpoint directly and mark itself human with a single raw HTTP request, defeating the whole point of this method. Always validate the proof (e.g. call out to Turnstile's/reCAPTCHA's own verify endpoint, or run your own check) in your controller/action first, and only call markAsHuman() after that check passes. This package deliberately does not ship a public route for this — there is no /monitor/... endpoint a client script can hit directly; the call has to live inside your own server-side code.

use Drcantagalo\LaravelMonitor\Facades\Monitor;

// Inside your own controller, AFTER verifying the Turnstile/reCAPTCHA
// token server-side (e.g. with a successful response from the provider's
// own verify endpoint) — never before.
if ($turnstileVerifiedServerSide) {
    Monitor::markAsHuman('turnstile');
}
  • Monitor::markAsHuman(?string $method = null): bool. Writes kind=human, classified_at=now() and source='verified' (no $method) or source='verified-{method}' ($method normalized with Str::slug() — lowercased, restricted to [a-z0-9-] — and truncated to 15 characters, e.g. markAsHuman('Cloudflare Turnstile') records source=verified-cloudflare-turn). Returns whether the write actually applied, same bool pattern as tag()/recognize()/addTag() above.
  • Precedence — manual > auth > verified-* > ai (MonitorLabel::isProtectedSource(), the same check that protects setMonitorKind against source=ai, see "IP classification" above):
    • If the Monitor's current kind was set with a protected source (manual, auth, or any existing verified*) this call is a silent no-op — it does not throw, does not change kind/ source, and, crucially, does not touch classified_at either: classified_at always stays at the time of the first accepted proof. Calling markAsHuman() again later with a different $method does not "update" anything — the first call's method and date always win.
    • If the current kind was set with source=ai (whether human or bot), or the Monitor has no classification at all yet, the call applies normally.
    • A monitor_labels row that only has tags/note and no kind yet (e.g. created earlier by addTag()) is treated the same as "no classification yet" — it is not protected, even though the source column already carries the database's own manual default for an unrelated reason (that default is about the column, not about an actual manual classification — see MonitorLabel:: isProtectedSource()'s docblock).
  • Never required to run before recognize()/tag(), but it does require session('monitor_id') to already exist — unlike addTag()/ addTags(), a call before the current visitor's Monitor exists is not queued as pending; it simply returns false.
  • Doesn't touch human_guest/human_user: those stay 100% derived from data.user_id (the reserved user tag — see "Authenticated user tagging" above), exactly as before. markAsHuman() never writes tags, only kind/source/classified_at.

Paginated page listing (getPages)

GET /monitor/handler?action=getPages — same auth as getData (the permanent local_token or the ephemeral read token from issueReadToken). Aggregates monitor_page_hits (hits/not_found, across every Monitor) into one entry per path (host/path) instead of shipping raw Monitor rows. As of 0.3.0, this listing no longer carries a scraper signal at the path level — a path like / could end up marked "possible scraper" just because one bot happened to pass through it once. The scraper heuristic still runs exactly the same, it's just scoped to the IP/visitor level now (see getVisitorsByIp below). flagScraperPath's honeypot mechanism (block a path + the IPs that already visited it) is unaffected — it never depended on this field.

Since 0.4.0, each path also carries a review status — pending (default, never reviewed) or safe (marked via markPathSafe, see above) — sourced from monitor_paths (status = 'safe' rows; see "Path review state" above), matched by suffix the same way blocked/monitor_paths (status = 'trap') already was. Since 0.20.3, status can only be 'safe' when the path is also not_found: true — monitor_paths matches by suffix regardless of host, on purpose (so one review protects every subdomain with the same trap), but that means a short/generic path (e.g. login) reviewed as safe on a host where it's a 404 must never leak the 'safe' badge to a different host where the same suffix is a real, non-404 route. A clean path (not_found: false) always reports status: 'pending', no matter what any monitor_paths row says.

  • page (default 1), per_page (default 20, max 100).
  • filter: pending_review (default when filter is omitted: not_found = true AND status != 'safe' AND blocked = false — the "still needs a human look" queue), all (the full dump — pass this explicitly to get the old default-listing behavior back), 404 (path was ever hit while the response was a 404), clean (not 404, not blocked), blocked (path has status: 'trap' in monitor_paths, matched by suffix the same way flagScraperPath does), safe (since 0.20.3: path has status: 'safe' — see below). An unknown filter value returns 422.
  • date_from/date_to (optional, any format Carbon accepts): since 0.60.0, filters exactly by the day column of monitor_page_hits (a hit is only counted if it happened on a day inside the window). Before 0.60.0 this filtered by the Monitor row's updated_at instead — "that visitor was active in this window" (summing hits from their entire lifetime, even outside the window) — because the schema had no per-day data; see exact_since below for the one caveat left from that change.
  • Response: {"success": true, "data": [{"path": "example.com/a", "hits": 12, "not_found": false, "blocked": false, "status": "pending"}, ...], "meta": {"page": 1, "per_page": 20, "total": 47, "last_page": 3}, "exact_since": "2026-10-04"}. Since 0.60.0: exact_since is the date the monitor_page_hits day-dimension migration ran on this installation (monitor_settings.page_hits_exact_since) — day values from that date on were written directly by recordHit() and are exact; values before it came from a one-time backfill (day = DATE(updated_at) of the old, dayless row) and are an approximation of "last time this path was hit", not every day it actually was. null if the migration hasn't run yet on this installation.

Result is cached (Cache::remember, TTL config('monitor.pages_cache_ttl_minutes'), default 5 minutes) keyed by a hash of the request params. Since the array/file cache drivers don't support Cache::tags(), invalidation works via a version counter instead: flagScraperPath/unflagPath/markPathSafe/unmarkPathSafe bump it, which changes every getPages cache key at once — old entries are simply never read again and expire on their own TTL, rather than being individually deleted.

Since 0.23.0, the aggregation itself no longer scans and JSON-decodes every Monitor row on a cache miss. A monitor_page_hits table is kept in sync automatically whenever a visitor hits a page — via Monitor::recordHit(), called by both trackers — and getPages aggregates it with a single GROUP BY query (SUM(hits), MAX(not_found)). This fixed a real production timeout: with ~35k Monitor rows, the old PHP-side scan measured ~85s on a cache miss, well past the 10s timeout a typical consumer (e.g. home-page) uses to call this endpoint. Upgrading ran a one-time backfill migration that populated monitor_page_hits from whatever Monitor.data already existed.

Since 0.60.0, the table is unique on (monitor_id, path, day) instead of (monitor_id, path) — see the breaking-change entry in CHANGELOG.md [0.60.0] for the full migration/backfill story and the date_from/date_to/exact_since semantics above.

Paginated visitor/blocklist listing (getVisitorsByIp, getBlockedIps, getBlockedPaths)

Same auth as getData/getPages (permanent local_token or the ephemeral read token from issueReadToken).

  • getVisitorsByIp: paginated/filterable listing of monitor_ip_stats (one row per unique IP, maintained by IpStat::recordVisit() on every tracked request — see "Per-IP stats" above). Params: page (default 1), per_page (default 20, max 100), filter (all default, flagged, clean, blocked, and since 0.49.0 the sub-filters clean_bots, clean_humans, clean_unclassified — see "IP classification" above; an IP counts as blocked if it has an active row in monitor_blocked_ips (BlockedIp::active(): permanent, or temporary and not yet expired); unknown value returns 422), date_from/date_to (optional, filters by the row's last_seen — "this IP was active in this window", same approximation as getPages), since 0.49.0 tag/kind (optional, combinable with any filter above — e.g. filter=blocked kind=bot lists blocked bots), and since 0.71.0 include_without_monitors (boolean, default false — see below). Since 0.72.0, tag accepts three special values besides an exact tag name: * (the IP has at least one Monitor with some custom tag — any tag except the reserved user), - (none of the IP's Monitors has one) and -foo (none of the IP's Monitors has the tag foo — works for user too, e.g. tag=-user lists IPs with no authenticated Monitor). Response: {"success": true, "data": [{"ip": "1.2.3.4", "visits": 3, "lifetime_visits": 186, "visit_count": 186, "first_seen": "...", "last_seen": "...", "flagged": false, "flagged_signals": null, "blocked": false, "kind": null, "tags": [], "counts": {"human": 0, "bot": 0, "unclassified": 1}}, ...], "meta": {"page", "per_page", "total", "last_page"}}. Breaking in 0.53.0: kind/tags are now derived on the fly from every Monitor ever seen at that IP (kind is bot/human only if ALL of them agree, mixed if they don't, null if none is classified; tags is their normalized union) instead of being read from a per-IP row — note/source/ classified_at are dropped from this response (they're per-Monitor now, with no single value that makes sense aggregated by IP — see getIpMonitors below for those), and a new counts object breaks down how many of that IP's Monitors fall in each kind. See "IP classification" above.
    • Regardless of which filter is requested, results are always ordered with flagged = true rows first (the "possible scraper" work queue), falling back, since 0.71.0, to visits desc (see below — it used to be visit_count desc, before that field was renamed/deprecated) within each group. Removed in 0.29.0: between 0.8.0 and 0.28.x, this also excluded/deprioritized IPs marked safe — there is no safe status for IPs anymore (see "Per-IP stats" above), so filter=flagged and the default ordering now depend only on flagged.
    • Since 0.71.0: visit_count (the lifetime counter straight from monitor_ip_stats, see "Per-IP stats" above — it's never decremented, including by a prune/clearData, see below) used to be the only "visits" number shown, which made a heavily-crawled IP look like it still had that many visits worth of Monitors long after most of them had been pruned/deleted — drilling into getIpMonitors for that same IP would show only a handful of Monitors with a couple of visits each. Two fields now cover this:
      • visits: the sum of the same per-Monitor visits_count that getIpMonitors/hydrateMonitorRows already expose (COUNT of monitor_visits, derived from the IP's current Monitors via monitor_visit_ips) — drops together with a prune/delete, matches the drill-down, and is the new default sort key (see above).
      • lifetime_visits: the same old monitor_ip_stats.visit_count, just under a name that says what it actually is. visit_count itself stays in the response too, deprecated, with the exact same value as lifetime_visits — so consumers written against 0.70.x and earlier don't break — and will be removed in a future major version.
      • include_without_monitors (boolean, default false): an IP with a monitor_ip_stats row but no current Monitor (exactly the post-prune case above) is excluded from this listing by default — visits would always be 0 for it anyway, and there's nothing to drill into via getIpMonitors. Pass true to bring these back, with visits: 0. meta.total/pagination reflect whichever set is in effect.
    • Since 0.12.0: filter=flagged also excludes IPs already present in monitor_blocked_ips — an IP that's already been blocked has already been confirmed as a scraper, so it no longer needs to show up in the "possible scraper" queue too. It still shows up under filter=blocked, as before.
    • Since 0.33.0: filter=blocked no longer starts from monitor_ip_stats — it queries monitor_blocked_ips directly (the actual source of truth for whether an IP is blocked, same table MonitorMethod::isBlocked() reads) and only optionally enriches each row with visit_count/first_seen/last_seen/flagged/ flagged_signals from monitor_ip_stats when that row still exists (null/false when it doesn't — the IP still shows up either way). Fixes a real bug: monitor:prune --only-blocked --older-than-days=0 running hourly (see below) deletes a blocked IP's monitor_ip_stats row within about an hour of the block, which used to make the IP silently disappear from this listing even though it was still actually blocked. date_from/date_to now filter by monitor_blocked_ips.last_offense_at instead of last_seen under this filter, and the response for each row also gains blocked_until, strike_count, lifetime_offense_count, last_offense_at and source (all from monitor_blocked_ips) — the existing fields keep their shape, nothing is removed.
    • Since 0.71.0: this row also gains visits/lifetime_visits, same meaning and derivation as the default listing above (visits from the IP's current Monitors, 0 if none; lifetime_visits from monitor_ip_stats.visit_count when that row still exists, null otherwise, same as visit_count). include_without_monitors doesn't apply to this filter: a blocked IP always shows up here regardless of whether it has a current Monitor — hiding it would undo the whole point of the 0.33.0 fix above.
    • Since 0.41.0: filter=blocked only lists active blocks (BlockedIp::active()) — a temporary block whose blocked_until has already expired no longer appears in this listing, same "blocked" definition used everywhere else in the package (the blocked field on filter=all/flagged/clean rows since 0.40.0, and MonitorMethod::isBlocked()). Before 0.41.0 this tab intentionally listed expired blocks too, as a history/reputation view; that was inconsistent with the rest of the package and was never actually requested, so it's reverted. The expired row itself is untouched in monitor_blocked_ips (still feeds strike_count/ lifetime_offense_count for the escalation ladder) — it just stops showing up here.
  • getVisitorPaths (since 0.6.0): given an ip ({"success": false, "message": "No valid IP provided"}, 422, if missing/invalid), finds every Monitor that's ever seen that IP and aggregates the paths (monitor_page_hits) it's been seen on — lets you confirm visually that an IP is a scraper before blocking it. No pagination/caching: the result set per IP is small and this is a lookup triggered on demand (e.g. expanding a row in the dashboard), not loaded on every page view. Response: {"success": true, "ip": "1.2.3.4", "paths": [{"path": "example.com/wp-admin/install.php", "hits": 3}, ...]}, sorted by hits descending.
    • Since 0.24.0: no longer scans and JSON-decodes every Monitor row to find which ones contain the IP (same bug class fixed for getPages in 0.23.0 — the cost used to be proportional to the size of Monitor, not to that IP's activity). A monitor_visit_ips table (one row per Monitor+ip, kept in sync by the same model event as monitor_page_hits) is looked up by ip via an index to get the relevant monitor_ids, then monitor_page_hits sums hits per path for just those ids — both steps in SQL. Upgrading runs a one-time backfill migration populating monitor_visit_ips.
  • getIpMonitors (since 0.46.0): given an ip ({"success": false, "message": "No valid IP provided"}, 422, if missing/invalid, same validation as getVisitorPaths), a paginated listing of the actual Monitor rows (devices/browsers) ever seen from that IP — complements getVisitorPaths (which only aggregates paths, without exposing the Monitor rows themselves). Params: page (default 1), per_page (default 20, max 100). Looked up the same way as getVisitorPaths (monitor_visit_ips.ip, indexed — never a scan of Monitor), ordered by updated_at descending (id descending as a deterministic tiebreaker). Response:
    {
      "success": true,
      "data": [
        {
          "id": 42,
          "created_at": "2026-09-10T12:00:00.000000Z",
          "updated_at": "2026-09-23T08:15:00.000000Z",
          "data": {"ua": "Mozilla/5.0 ...", "flags": {"scraper": false, "scraper_signals": []}},
          "ips": ["203.0.113.9", "203.0.113.14"],
          "visits_count": 7,
          "kind": "bot", "tags": ["amazon"], "note": null, "source": "ai", "classified_at": "2026-09-24T10:00:00.000000Z"
        }
      ],
      "meta": {"page": 1, "per_page": 20, "total": 1, "last_page": 1}
    }
    • data (the blob) is sanitized the same way as getUserMonitors — see "Sanitizing the data blob" below.
    • ips: every IP that Monitor has ever been seen from (not just the one searched for), and visits_count: COUNT(*) of monitor_visits for that Monitor — both resolved with one grouped whereIn query per page (not one query per row), same shared hydrateMonitorRows helper getUserMonitors/getMonitorQueue use.
    • kind/tags/note/source/classified_at: that Monitor's own classification (monitor_labels) — see "IP classification" above. Between 0.49.0 and 0.52.0 these lived per-entry inside ips (each IP carrying its own classification); breaking in 0.53.0: the classification moved from the IP to the Monitor itself, so ips reverted to a plain list of strings and these five fields moved up to sit directly on the row.
    • data.name/data.email (since 0.57.0): when the row has a user_id, these are the resolved contact for that user, not this Monitor's own data.name/data.email — see "Stable across a user's Monitors" under "User listing" above. Rows without a user_id keep whatever was already in data untouched.
    • Cached the same way as getVisitorsByIp/getBlockedIps (see below).
  • getMonitorVisits (since 0.46.0): given a monitor_id ({"success": false, "message": "monitor_id is required"} / "monitor_id must be an integer" / "Monitor not found", 422, when missing, non-integer, or not an existing Monitor id, respectively), a paginated listing of that device/browser's full visit history (monitor_visits), newest first (id descending). Unlike getIpMonitors/getUserMonitors (which only expose visits_count, a number, per row), this action lets you page through the complete journey history of one specific Monitor on demand (e.g. an "see all visits" expansion in the dashboard). Params: page (default 1), per_page (default 20, max 50). Response:
    {
      "success": true,
      "data": [
        {
          "id": 981,
          "ip": "203.0.113.9",
          "paths": ["example.test/", "example.test/cart", "example.test/checkout"],
          "scraper": false,
          "created_at": "2026-09-23T08:00:00.000000Z",
          "updated_at": "2026-09-23T08:04:00.000000Z"
        }
      ],
      "meta": {"page": 1, "per_page": 20, "total": 1, "last_page": 1}
    }
    • ip: the IP that opened that specific visit — see "Visits" below (monitor_visits.ip, new in 0.46.0); null for a visit recorded before this column existed (not backfilled).
    • paths: the full journey for that visit, in access order (same shape as each entry of monitor_visits — see "Visits" below).
    • Cached the same way as getVisitorsByIp/getBlockedIps (see below).
  • getBlockedIps / getBlockedPaths: plain paginated listing of monitor_blocked_ips ({"ip", "source", "created_at"}) / monitor_paths rows with status: 'trap' ({"path", "created_at"}, same shape as before 0.20.0) — no filter param, just page/per_page. Ordered newest-first.

Unlike getPages (which has to aggregate a JSON blob per Monitor row in PHP), these three query normalized tables directly, so filtering/ordering/pagination happen in SQL via a real Model::paginate().

Cached the same way as getPages (Cache::remember + a version counter, TTL config('monitor.listings_cache_ttl_minutes'), default 5 minutes) but with its own counter (monitor:listings:version), kept separate from getPages' so this change doesn't touch its already released cache. updateBlockedIps, unblockIp, flagScraperPath, and unflagPath all bump it, since every one of them changes blocked-state data these three actions read.

Table stats (getTableStats)

Since 0.50.0: a read-only, no-pagination snapshot of how much data this package's own tables currently hold — one row per table it creates (monitors, monitor_visits, monitor_visit_ips, monitor_page_hits, monitor_ip_stats, monitor_blocked_ips, monitor_paths, monitor_block_results, monitor_labels) plus a total summing all of them. Meant to feed a "storage" panel on the dashboard and help decide when it's worth running pruneData/clearData/monitor:prune — without needing direct database access.

Same auth as getData (permanent local_token or the ephemeral read token from issueReadToken). Response:

{
  "success": true,
  "data": [
    {"table": "monitors", "rows": 1204, "size_bytes": 933888},
    {"table": "monitor_visits", "rows": 5031, "size_bytes": 2113536},
    {"table": "monitor_labels", "rows": 42, "size_bytes": 16384}
  ],
  "total": {"rows": 6277, "size_bytes": 3063808}
}
  • rows: an exact COUNT(*) for that table.
  • size_bytes: data + index size for that table (DATA_LENGTH + INDEX_LENGTH from information_schema.tables), MySQL only — null on every other driver (SQLite, PostgreSQL, ...), since there's no equally cheap/portable equivalent across drivers and the package doesn't promise this figure outside MySQL. total.size_bytes is likewise null unless every table's own size_bytes was resolved.
  • A table this package knows about but that hasn't migrated yet in the consuming app (e.g. right after upgrading, before php artisan migrate) is skipped, not an error — Schema::hasTable() gates every row, so getTableStats never 500s just because an installation is a migration behind.

Cached the same way as getVisitorsByIp/getBlockedIps (Cache::remember

  • the same shared monitor:listings:version counter, TTL config('monitor.listings_cache_ttl_minutes'), default 5 minutes) — clearData and pruneData (see below) both invalidate it, so the numbers never stay stale longer than one cleanup call.

Effective config (getConfig, since 0.54.0)

Read-only, no-pagination snapshot of the client's current effective config/monitor.php, for the dashboard's "Data" tab. Meant to answer "what is this installation's config actually set to right now, and is it up to date" without shell/database access to the client's server.

Read-only by design: this package does not have, and will never have, an action that writes config on the client's server. getConfig only ever reads.

Same auth as getData/getTableStats (permanent local_token or the ephemeral read token from issueReadToken), and cached the same way as getVisitorsByIp/getBlockedIps/getTableStats (Cache::remember, TTL config('monitor.listings_cache_ttl_minutes'), default 5 minutes, same shared monitor:listings:version counter — bumped by the same mutations as getTableStats).

{
  "success": true,
  "meta": {
    "package_version": "0.54.0",
    "config_version": "0.53.0",
    "version_diverged": true,
    "config_published": true,
    "config_cached": false
  },
  "config": {
    "visits_retention_days": {
      "value": 0, "default": 0, "customized": false,
      "missing_from_file": false, "env": null
    },
    "ignore_ips": {
      "value": "2 IPs", "default": "0 IPs", "customized": true,
      "missing_from_file": true, "env": "MONITOR_IGNORE_IPS"
    }
  }
}
  • meta.package_version: same value as getData's package_version (the version actually installed, via Composer's InstalledVersions).
  • meta.config_version: same value as getData's config_version (config('monitor.version'), frozen at whatever monitor:install/ monitor:update last wrote into the client's published config/monitor.php).
  • meta.version_diverged: true when both of the above are known and don't match — Composer was updated but monitor:update wasn't run afterward (config, and possibly migrations, may be pending). Since 0.56.0 a leading v/V is ignored on both sides before comparing: package_version comes from the Git tag (v0.54.0) while the config stores 0.54.0, which used to be reported as diverged on every tag-based install.
  • meta.config_published: whether config/monitor.php is published at all in this project (vendor:publish --tag=monitor-config / monitor:install ran at some point). When false, every key below is missing_from_file: true — the client is 100% on package defaults.
  • meta.config_cached: app()->configurationIsCached() — whether the host app is currently running with php artisan config:cache.

config: one entry per key in a fixed whitelist the package ships (never the full config('monitor') array — a key only appears here if deliberately added to this list in a package release):

  • Retention/prune: visits_retention_days, access_log_retention_days, data_prune_interval_hours, data_prune_max_rows_per_run, blocked_ips_cleanup_interval_hours.
  • Scraper detection: scraper_frequency_window_seconds, scraper_frequency_threshold, scraper_signal_threshold, scraper_known_bot_user_agents (masked — see below).
  • Auto-block: auto_block_signal_threshold, auto_block_strike_decay_cooldown_days, auto_block_permanent_after_lifetime_offenses.
  • AI triage: ai_triage_min_age_hours.
  • Cache: listings_cache_ttl_minutes, pages_cache_ttl_minutes, data_totals_cache_ttl_seconds, block_results_cache_ttl_seconds, blocked_ip_cache_ttl.
  • Tracking: track_visits, track_authenticated_user, visit_max_paths, denylist_format, denylist_export_interval_hours, ignore_ips (masked), denylist_path (masked).

Identity/infra keys (dashboard_origin, remember_cookie, remember_cookie_days, skip_session_key, local_token, read_token_ttl_minutes, dashboard.enabled, version) are deliberately never included — getConfig is about behavior, not about how to reach or authenticate against this installation.

Each entry has:

  • value: the effective value, via config('monitor.<key>') (already merged with the package's own default for any key missing from the published file).
  • default: the package's own default, read straight from its own src/config/monitor.php (never from config(), which would already be merged).
  • customized: true when value differs from default.
  • missing_from_file: true when the key is absent from the client's published config/monitor.php file (not just from config(), which already merges package defaults in for missing keys) — meaning the client is silently on the package default for that key, and a monitor:update is likely pending to persist it.
  • env: the name of the .env variable the key reads from, or null when the key isn't backed by one.

Masking — two keys never expose their raw value, only a package/client-safe summary, regardless of customized/comparisons still being computed on the real underlying value:

  • ignore_ips: value/default are a string like "2 IPs" — the actual IP/CIDR list is never returned.
  • denylist_path: value/default are just the filename (basename()), never the absolute path on the client's server.
  • scraper_known_bot_user_agents: value/default are the count of substrings in the list, never the list itself.

Daily timeline (getTimeline, since 0.58.0)

Read-only, no-pagination daily series for the dashboard's "Overview" charts: new visitors by classification, visits by classification, and reads of the client's data. Same auth as getData/getTableStats (permanent local_token or the ephemeral read token from issueReadToken), and cached the same way as getTableStats (Cache::remember, TTL config('monitor.listings_cache_ttl_minutes'), key including the days parameter since each value has its own result).

days (query/body param, integer, default 30): the window is "today" plus the days - 1 days before it. Must be between 7 and 365 — anything else (non-integer, out of range) is a 422, never a silently clamped or unbounded query.

{
  "success": true,
  "timezone": "America/Sao_Paulo",
  "days": ["2026-09-26", "2026-09-27", "2026-09-28", "2026-10-02"],
  "series": {
    "monitors_new": {
      "human": [1, 0, 2, 3],
      "human_guest": [1, 0, 1, 2],
      "human_user": [0, 0, 1, 1],
      "bot": [0, 1, 0, 2],
      "flagged": [1, 0, 0, 1],
      "unclassified": [3, 2, 1, 0]
    },
    "visits": {
      "human_guest": [7, 6, 9, 10],
      "human_user": [2, 1, 2, 3],
      "bot": [1, 0, 2, 1],
      "flagged": [0, 1, 1, 0],
      "unclassified": [1, 0, 2, 2]
    },
    "access": {
      "read_token_issued": [3, 3, 4, 5],
      "read_token_first_use": [3, 2, 4, 4],
      "local_token_read": [12, 10, 14, 16]
    }
  }
}
  • timezone: config('app.timezone'), echoed back so the dashboard never has to guess which timezone the days dates are in.
  • days: one "YYYY-MM-DD" entry per day of the window, in the app's timezone, oldest first — never UTC-fixed. A day with zero events for a given series is 0, not a gap: every array in series has exactly as many entries as days, aligned 1:1 by index.
  • series.monitors_new (categories expanded in 0.59.0): every Monitor whose created_at falls on that day, split into the same categories as getData.monitors_by_kind — human_guest/human_user (kind=human, by user_id presence), bot (kind=bot), flagged (no kind, seen at a flagged/blocked IP), unclassified (no kind, the rest). human is kept as human_guest + human_user for compatibility. Unlike getData, there's no separate new bucket here — the AI-triage grace period only makes sense for an "as of now" total, not a daily series, so those Monitors fall under unclassified. All of this reflects the Monitor's current state, not whatever it was on that historical day — reclassifying a Monitor later shifts which bucket its day counts toward.
  • series.visits (breaking change in 0.59.0: clean/scraper removed): every monitor_visits row whose created_at falls on that day, split by the category of the Monitor that owns the visit (monitor_visits.monitor_id) — same 5 categories as series.monitors_new above (minus the legacy human key, which series.visits never had). This is a category of the visitor, not of the individual visit: a visit from a Monitor later reclassified as bot moves to the bot bucket on its next getTimeline read, same as series.monitors_new.
  • series.access: every monitor_access_logs row whose accessed_at falls on that day, split by kind (see "Access log" below) — read_token_issued is included for completeness but is deliberately left out of the dashboard's chart (it reflects the cantagalo.it backend opening the dashboard, not an actual data read).
  • Fail-open per series: a table that hasn't migrated yet (e.g. right after upgrading, before php artisan migrate) makes that series — and only that series — come back all zeros, with a Log::warning, same pattern as getData/getTableStats. It never turns the whole action into a 500.
  • Indexed on monitors.created_at and monitor_visits.created_at (migration 2026_10_02_000000_add_created_at_indexes_for_timeline) — without it this query would scan the full table on every cache miss.

Out of scope, on purpose: blocked IPs/attempts per day. monitor_blocked_ips deletes its row on unblock/expiry (BlockedIpCleaner) and monitor_block_results.counter is accumulated per IP — neither has an honest daily history to report. Left for a post-1.0 daily rollup table (monitor_daily_stats).

Per-path daily timeline (getPageTimeline, since 0.60.0)

Read-only, no-pagination daily series of hits for one specific path — the "click a path in getPages to see its history" dashboard chart (home-page 287). Same auth/cache scheme as getTimeline above (Cache::remember, listings_cache_ttl_minutes, key including path and days).

  • path (required): the exact host/path key, same string as returned by getPages' data[].path. Matched exactly — unlike flagScraperPath/markPathSafe, there's no suffix matching here, since the caller already has the exact key from a getPages row. Missing/ empty is a 422.
  • days (optional, integer, default 30): same 7–365 range and validation as getTimeline's days.
{
  "success": true,
  "path": "example.com/a",
  "timezone": "America/Sao_Paulo",
  "days": ["2026-09-26", "2026-09-27", "2026-09-28", "2026-10-02"],
  "hits": [3, 0, 5, 2],
  "exact_since": "2026-10-04"
}
  • timezone/days: same semantics as getTimeline — the app's timezone, oldest first, a day with zero hits is 0, never a gap.
  • hits: monitor_page_hits.hits summed across every Monitor that hit this path, grouped by day, aligned 1:1 with days by index.
  • exact_since: same value and meaning as getPages' exact_since (see above) — days before it come from the one-time backfill (day = DATE(updated_at)), not from a real daily count, so a long window spanning that date can undercount/misplace older hits onto whatever day the visitor happened to last be seen.
  • Fail-open: a monitor_page_hits table that hasn't migrated to the day column yet comes back as an all-zero series with a Log::warning, same pattern as getTimeline, never a 500.

Access log (monitor_access_logs, since 0.51.0)

Transparency guarantee: every read of your data leaves a line in a log that lives only on your own server, which only you can edit or delete. Motivation: a client site's admins might otherwise worry that the people running the hosted dashboard (monitor.cantagalo.it) can read their data without any trace. Password/MFA protection on the dashboard side was considered and deliberately not built yet — this log is the transparency measure that ships instead. It's purely additive: nothing about how getData/getPages/etc. behave changes.

A new append-only table, monitor_access_logs, gets one row per:

  • issueReadToken call (kind: "read_token_issued") — this happens every time the hosted dashboard is opened for this site. Note: the caller here is always our server (cantagalo.it) minting a token on the end user's behalf, so ip/user_agent on this row are ours, not the dashboard user's — they don't identify who actually opened the dashboard.
  • the first valid use of each read token (kind: "read_token_first_use") — the first request that actually uses the token returned by issueReadToken. This one is evidence from the browser side (ip/user_agent/origin never pass through our server), and is the row that answers "who really opened this". Exactly one row per token: later requests with the same token don't add more. token_ref (a short hash of the token, never the token itself) links this row back to its read_token_issued row.
  • every read action called with the permanent local_token (kind: "local_token_read") — the proxy/fallback path and the AI triage job both read this way. Without this row, someone who wanted to read quietly could just use the permanent token and leave the log empty.

declared_by (optional, on issueReadToken and on local_token reads): a short string the dashboard's server says identifies who asked for this access — the logged-in dashboard user's email, or e.g. "AI triage run #42". It's stored and shown as-is, labeled "declared by dashboard" wherever it's displayed — unlike the browser's IP on the first-use row, this is not independently verified, just what the dashboard chose to send.

A failure to write this log never breaks the request it's logging (same fail-open pattern as the rest of the package for a not-yet-migrated table).

Remote access is read-only. A new action, getAccessLog (paginated, most recent first, same auth as getData — permanent local_token or an ephemeral read token), lets the dashboard show these rows. Calling it never adds a row of its own — otherwise every time someone opened the log it would grow by one. No remote action can edit or delete rows here: clearData and pruneData (and the Support\DataPruner::prune() they both call) never touch monitor_access_logs.

{
  "success": true,
  "data": [
    {
      "id": 42, "accessed_at": "2026-09-27T14:02:11.000000Z",
      "kind": "read_token_first_use", "action": "getData",
      "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 ...",
      "origin": "https://monitor.cantagalo.it", "token_ref": "a1b2c3d4e5f60718",
      "declared_by": null
    }
  ],
  "meta": {"page": 1, "per_page": 20, "total": 137, "last_page": 7}
}

Retention: config('monitor.access_log_retention_days') (default 90, 0 = never delete) is applied automatically, on the client's own server, with no cron required — it's part of the same automatic trigger that already prunes tracking data (Support\DataPruner::maybeCleanup(), gated by data_prune_interval_hours), through a dedicated method (DataPruner::pruneAccessLogs()) that neither monitor:prune nor the pruneData HTTP action can reach. This value only ever comes from the local config — there's no remote action that can change it.

monitor:access-log (local, artisan-only command — the actual source of truth, since the hosted dashboard is, in principle, capable of filtering what it shows):

php artisan monitor:access-log                  # last 50 entries
php artisan monitor:access-log --days=7         # only the last 7 days
php artisan monitor:access-log --limit=200
php artisan monitor:access-log --purge          # asks for confirmation, then deletes everything

Dashboard remote access toggle (monitor:dashboard, since 0.64.0)

Turn the hosted dashboard's (monitor.cantagalo.it) remote access to this site on or off at runtime, without touching code or redeploying:

php artisan monitor:dashboard status            # shows on/off (+ expiration, if any)
php artisan monitor:dashboard off                # closes remote access now
php artisan monitor:dashboard on                 # opens it again, no expiration
php artisan monitor:dashboard on --for=2h        # opens it for 2 hours, then closes by itself
php artisan monitor:dashboard on --for=30m       # minutes
php artisan monitor:dashboard on --for=1d        # days

This is deliberately separate from config('monitor.dashboard.enabled'): that config key is an installation choice ("does this site use the dashboard at all?") made once via monitor:install, decides whether the /monitor/handler route is even registered, and lives in versioned code (config/monitor.php). monitor:dashboard, instead, is a runtime toggle a site owner flips whenever they want to open the dashboard for a bit and close it again — state lives in storage/monitor/dashboard-access.json (next to installation.json, not in versioned config, so a deploy never overwrites it or leaves the working tree dirty), and is read on every request with no cache, so a toggle takes effect on the very next call.

With access off, /monitor/handler stays registered, but every action through it (there is no site-initiated action on this route today — registration is outbound, from the site to the dashboard, and there is no heartbeat — so the gate applies uniformly, with no exception list) gets rejected with a stable, specific response, HTTP 403:

{
  "success": false,
  "message": "Dashboard access has been turned off by the site owner",
  "code": "dashboard_access_disabled"
}

The dashboard recognizes code: "dashboard_access_disabled" and shows a clear message instead of a generic error. This check runs before token auth — even a request with a valid local_token is rejected while access is off, since the point is to fully close remote access, not just hide it from unauthenticated callers.

Nothing else changes: tracking, blocking, pruning and every other local job keep running normally with access off — only the remote (dashboard-initiated) path is closed. An on --for=<duration> expiration is checked on read, not by a scheduler — so it reliably closes itself even if nothing else runs in the meantime.

Upgrading: existing installations default to access on (no dashboard-access.json file = on) — updating the package never locks anyone out of a dashboard they were already using.

Data cleanup (clearData, since 0.63.0 accepts parameters)

POST /monitor/handler with action=clearData — same auth as updateBlockedIps: requires the permanent local_token, never accepted with the ephemeral read token from issueReadToken.

Since 0.63.0, clearData unifies what used to be two separate actions (clearData, a full truncate with no parameters, and pruneData, a partial/filtered delete requiring older_than_days — pruneData still exists, see "Deprecated: pruneData" below) into one, with two optional parameters:

  • older_than_days (non-negative integer; absent or empty string = no age cutoff, i.e. every row regardless of age — this is what makes "no parameters at all" behave exactly like the old clearData). 422 if present and not a non-negative integer.
  • categories[] (array; absent = all categories). Each value must be one of the 5 categories getData.monitors_by_kind already uses — human_user, human_guest, bot, flagged, unclassified — see "Aggregated dashboard totals" above and Support\MonitorCategories (the single place this rule lives; MonitorController and Support\DataPruner both read it from there, so the category used to decide what to delete can never drift from the category used to count monitors_by_kind). 422 on an empty array, a non-array, or any value outside those 5. unclassified here also covers the new bucket that monitors_by_kind reports separately (a Monitor with no kind, not flagged, regardless of how recently it was created) — a deliberate product decision, not an oversight: age only matters for new in that one aggregate, never for deciding what a cleanup should delete.

What actually gets deleted, given the two parameters above:

  • No parameters at all: byte-for-byte the pre-0.63.0 clearData — every Monitor row is deleted (monitor_page_hits/ monitor_visit_ips/monitor_visits of each go with it via the FK ON DELETE CASCADE), and monitor_ip_stats is never touched.
  • All 5 categories selected (explicitly, or just by omitting categories) + older_than_days present: identical to the old pruneData — Monitor/monitor_ip_stats rows older than the cutoff are deleted, plus the monitor_visits/monitor_page_hits sweeps described in "Visit retention" below.
  • A category filter that is not "all 5" (regardless of older_than_days): only monitors rows matching the category (and the age cutoff, if given) are deleted — the FK cascade takes care of that Monitor's own monitor_page_hits/monitor_visit_ips/ monitor_visits. monitor_ip_stats is per-IP, not per-Monitor — there's no way to restrict it by category without reintroducing the IP↔Monitor mismatch problems monitor_labels was built to avoid (see "IP classification" above) — so it's left completely untouched whenever the category filter isn't "all 5", even if older_than_days is also given. monitor_access_logs is never touched by any of this, same pre-existing rule as always (see "Access log" above).

Note (since 0.71.0): deleting monitor_ip_stats rows this way (the "all 5 categories + older_than_days" branch above) is the only thing that removes a monitor_ip_stats.visit_count (lifetime_visits/visit_count in getVisitorsByIp, see above) — it's a whole-row delete by last_seen age, an explicit, user-triggered cleanup. It is never reset/decremented as a side effect of deleting or pruning Monitor rows (the branches above that only delete monitors/their cascaded children) — it's the IP's block-escalation history read (see "Per-IP stats" above), and silently zeroing it whenever a Monitor is cleaned up would "launder" a scraper's history. getVisitorsByIp's visits field (the one that does track current Monitors) is what drops when Monitors are pruned/deleted — lifetime_visits only moves when the IP's own monitor_ip_stats row is itself deleted or ages past a cutoff here.

Response: {"success": true, "monitors_deleted": 12, "ip_stats_deleted": 4, "visits_deleted": 3, "page_hits_deleted": 1} — ip_stats_deleted/ visits_deleted/page_hits_deleted are always 0 whenever the category filter isn't "all 5" (see above).

Fixed in 0.64.0: with the "all 5 categories + older_than_days" branch above, visits_deleted/page_hits_deleted used to undercount whenever a monitor_visits/monitor_page_hits row's own Monitor was also old enough to be pruned in the same call — Support\DataPruner:: pruneMonitors() deletes that Monitor first, and the FK's ON DELETE CASCADE removes those child rows immediately, before the separate monitor_visits/monitor_page_hits sweeps that follow ever get a chance to count them. No data was ever lost or left behind (the right rows were always deleted) — only the number reported in the response/monitor:prune output could be lower than the real count. Fixed by counting each Monitor's cascade-bound children right before deleting it (DataPruner::countCascadedChildren()) and adding that into the totals — see bugs/laravel-monitor.md history in claude-manager for how this was found.

Chunked (Support\DataPruner::pruneByCategory(), same batching principle as the rest of DataPruner — never an unbounded DELETE for a potentially large table, even when called manually with no row cap). Invalidates the getPages/getVisitorsByIp/getTableStats listing cache version counters (invalidatePagesCache/invalidateListingsCache, same mechanism as flagScraperPath/updateBlockedIps etc. — only when something was actually deleted) and, new since 0.63.0, the monitor:data:monitors-by-kind cache key whenever a Monitor row was deleted — that cache previously only expired on its own fixed TTL (data_totals_cache_ttl_seconds, default 45s), never invalidated by a write, so the dashboard's totals could lag a manual cleanup by up to 45s.

Preview (previewClearData, since 0.63.0)

POST /monitor/handler with action=previewClearData — same parameters, same validation, same auth as clearData above, but read-only: runs the exact same queries (Support\DataPruner:: previewByCategory(), ->count() instead of ->delete() — never a second, hand-written counting query that could drift from what clearData actually deletes) and returns how many rows would be deleted, without deleting anything. Same response shape as clearData (monitors_deleted/ip_stats_deleted/visits_deleted/ page_hits_deleted). Meant for the dashboard to show a confirmation summary ("this will delete N monitors, M IP stats, ...") before the user commits to the actual clearData call.

Clearing the dashboard's listing caches (clearCache, since 0.76.0)

POST /monitor/handler with action=clearCache — same auth as updateBlockedIps/clearData (permanent local_token only, not accepted with the ephemeral read token from issueReadToken). No cooldown/throttle: only a caller that already holds local_token can reach this at all, so an explicit decision was made not to add one on top.

Bumps every cache the dashboard's listing/aggregate endpoints use, so the very next read recomputes instead of serving something stale:

  • The versioned caches getPages/getVisitorsByIp/getBlockedIps/ getBlockedPaths/getUserMonitors/getIpMonitors/getMonitorQueue/ getMonitorQueueCounts/getTableStats/getConfig already use (monitor:pages:version/monitor:listings:version — the same counters any write action, like updateBlockedIps or setMonitorKind, already bumps on its own).
  • getBlockResults/getApiBlockEvents's own cache, which lives outside that versioned scheme (short, fixed TTL — block_results_cache_ttl_seconds — because it mutates on every blocked request, not on an admin action; see "Blocked-attempt counter" below). Its cache key includes a hash of page/per_page, so there is no fixed key to Cache::forget() directly — it gets the same version-counter treatment instead (Support\BlockResultsListCache).
  • The fixed-key "data totals" caches (data_totals_cache_ttl_seconds) — visitors_total/monitors_by_kind/unique_ips_total from getData, plus blocked_attempts_total (same TTL family as getBlockResults above, but a predictable key) — forgotten directly, no version needed.

Deliberately leaves alone: the middleware's own IP/path block caches (monitor:blocked-ip:*/monitor:blocked-path:*) and the ephemeral read-token cache (monitor:read-token:*) — clearing the first would briefly un-block an actively-blocked IP until the next request re-primes the cache, and the second has nothing to do with dashboard listings.

Deprecated: pruneData

pruneData still works, mapped internally to clearData with the request's older_than_days and all 5 categories — never a second implementation, see Support\DataPruner::pruneByCategory(). Two differences from calling clearData directly, kept on purpose so an out-of-date dashboard mid-deploy doesn't break:

  • older_than_days stays required here (422 if missing or invalid) — unlike clearData, where it's optional (absent = no age cutoff). pruneData never had an "optional cutoff" concept, and keeping it required avoids silently changing what an existing pruneData call does.
  • The HTTP response keeps the old shape — the same 4 count fields clearData returns today, but guaranteed never to gain a new field silently, since an old consumer parses this response expecting exactly these keys.
  • categories, if an updated client sends it anyway, is silently ignored — pruneData has no concept of a category filter and always sweeps all 5, same as only_blocked has been silently ignored here since 0.50.0 (see below).

⚠️ Breaking change in 0.50.0: this HTTP action no longer accepts only_blocked — it always runs the full sweep described above (the old only_blocked=false behavior), regardless of what the request sends. If a request body still includes only_blocked (an out-of-date consumer mid-deploy), it's silently ignored, never rejected with 422. Restricting the delete to rows belonging to a currently-blocked IP is now a CLI-only capability — see monitor:prune --only-blocked below, whose behavior (and the $onlyBlocked parameter on Support\DataPruner::prune() it calls) is unchanged. ⚠️ Breaking change in v0.7.0: this parameter was named only_scraper_flagged and matched data.flags.scraper/ IpStat.flagged instead — the automatic, non-cumulative heuristic signal from the last request seen from that IP, never reviewed by anyone. That made pruneData capable of permanently deleting rows for an IP on an unreviewed false positive. It was changed to match monitor_blocked_ips (an IP the user actually confirmed/blocked) instead. ⚠️ Behavior change in v0.38.0: "blocked" (CLI --only-blocked, and the HTTP action's only_blocked before 0.50.0) used to mean any row in monitor_blocked_ips, including a temporary block whose blocked_until had already expired — that row intentionally lives on after expiry (it feeds the escalating block duration on the next offense, see "Manual IP blocking"/ScraperBlocker), but treating it as still-blocked for pruning purposes meant an IP that served its temporary block and went back to being a normal visitor kept having its tracking wiped every automatic cleanup cycle. It now only matches a currently-active block (permanent, or temporary and not yet expired) — same check MonitorMethod itself uses to decide whether to block a request (BlockedIp::active()).

monitor:prune (since 0.27.0)

Artisan equivalent of clearData/pruneData above, same underlying Support\DataPruner (chunked/indexed, never ::all()/cursor() over the whole Monitor table), same cache invalidation — meant to run from the consuming app's own scheduler instead of a manual HTTP request. Since 0.50.0 this command is also the only way to restrict the prune to confirmed-blocked IPs (--only-blocked) — the HTTP action dropped that option, see the breaking-change note above:

php artisan monitor:prune --only-blocked --older-than-days=0
  • --older-than-days= (required, non-negative integer — command fails with a non-zero exit code if missing or invalid): same cutoff semantics as clearData's older_than_days (but, unlike the HTTP action, always required here, with or without --category).
  • --only-blocked (optional flag, default off, CLI-only since 0.50.0): restrict the delete to rows belonging to a confirmed/blocked IP (monitor_blocked_ips) instead of every row past the cutoff.
  • --category= (optional, repeatable, since 0.63.0): restrict the delete to a subset of the same 5 categories clearData's categories[] accepts (human_user/human_guest/bot/flagged/ unclassified) — same rule, same monitor_ip_stats/monitor_visits/ monitor_page_hits carve-out (only pruned when every category is given). Mutually exclusive with --only-blocked — the command fails with a non-zero exit code if both are passed, since they filter along different axes (IP reputation vs. Monitor classification) and combining them isn't supported. Example: php artisan monitor:prune --older-than-days=30 --category=bot --category=flagged.

Automatic since 0.32.0 — you no longer need to schedule anything for this: Support\DataPruner::maybeCleanup() runs prune(0, true) (purge tracking data for confirmed-blocked IPs) by itself, on a deterministic cache-backed timer (monitor.data_prune_interval_hours, default 1), checked on every tracked request — same mechanism as Support\BlockedIpCleaner::maybeCleanup(). An IP already purged under this filter never reappears in it, so this stays cheap even checked on every request. Manually scheduling monitor:prune --only-blocked --older-than-days=0 (e.g. via Schedule::command(...)->hourly() in the consuming app) is now optional/redundant — the command itself still exists for manual/administrative use (e.g. --older-than-days greater than 0, to prune by age without the blocked-IP filter, which is only useful on demand).

Concurrency-safe and capped since 0.36.0 — because the automatic trigger runs inline in a visitor's own request (no queue), two safeguards protect it from running away on a busy site:

  • Lock: maybeCleanup() takes an atomic Cache::add lock before pruning and releases it in a finally block. If two requests race past the interval check at the same time, only one actually prunes; the other is a no-op. A short, non-configurable safety TTL (60s) on the lock itself guards against a process dying before it can release it (e.g. an OOM kill) — it's not meant to bound how long a normal run takes.
  • Row cap: monitor.data_prune_max_rows_per_run (default 1000) caps how many Monitor rows a single automatic run deletes. If the backlog is larger than the cap, the run deletes one capped batch and does not advance the "last run" timestamp — the very next tracked request picks up where it left off (the query is re-run from scratch each time, not a frozen list, so nothing about a since-unblocked IP can go stale). monitor_ip_stats isn't capped — it's bounded by the size of monitor_blocked_ips itself, not by the tracking backlog, so it stays cheap without chunking.

This only affects the automatic trigger. monitor:prune and pruneData (manual/administrative use) are always uncapped and delete everything matching the filter in one go, exactly as before.

Visit retention (since 0.42.0)

monitor.visits_retention_days deletes monitor_visits rows whose updated_at (last activity) is older than that, independent of the age of the parent Monitor: a device recognized by the 5-year remember-me cookie is never deleted by the prune above, so without this its visits would pile up forever. It runs inside DataPruner::prune() — the automatic trigger (capped by data_prune_max_rows_per_run, same "resume on the next request" behavior), monitor:prune and pruneData — and the visits of a deleted Monitor go away through the foreign key's ON DELETE CASCADE.

⚠️ Behavior change in 0.46.0: the default changed from 90 to 0 (disabled — retention is now opt-in/manual). Until 0.45.0, visits_retention_days defaulted to 90 and the automatic trigger (DataPruner::maybeCleanup(), run on every tracked request) called prune(0, true), which already included this retention sweep — meaning every installation that never published config/monitor.php (most of them) was already auto-deleting monitor_visits older than 90 days, silently, by accident. That was never the intent: the automatic trigger exists to purge tracking for already-confirmed-blocked IPs (only_blocked=true), not to sweep everyone's visits by age. Two concrete consequences:

  • A site that never published config/monitor.php loses the old automatic 90-day visit retention on upgrade to 0.46.0 — visits now accumulate indefinitely unless you opt back in (see below). This is the majority of installations (publishing the config is optional).
  • A site that already published config/monitor.php keeps whatever value it has in that file — monitor:update never overwrites a key you've customized (see "Updating" above), so nothing changes for you here.

To restore automatic visit cleanup, either (a) publish config/monitor.php (if not already) and set visits_retention_days to a value > 0 — the automatic trigger keeps honoring it exactly like before 0.46.0 — or (b) schedule monitor:prune --older-than-days=X (without --only-blocked) yourself, which as of 0.46.0 also sweeps monitor_visits — see next paragraph.

New in 0.46.0: DataPruner::prune($olderThanDays, $onlyBlocked) now additionally deletes monitor_visits rows older than the same $olderThanDays cutoff used for Monitor/monitor_ip_stats, whenever $onlyBlocked is false — independent of, and on top of, visits_retention_days. This is what pruneData (only_blocked=false, the dashboard's "partial cleanup" UI) and monitor:prune --older-than-days=X (without --only-blocked) use to let you manually sweep old visits from active devices even with retention disabled — the tool that replaces the old accidental automatic behavior. It's a second, independent DELETE (no double-counting: a row either query already removed just isn't found by the other), and its count is folded into the same visits_deleted. This new sweep never runs from the automatic trigger (maybeCleanup() always calls prune(0, true) — only_blocked=true), so the automatic path still never deletes a visit on its own with the default config, exactly as intended.

monitor:prune's output and DataPruner::prune()'s return value gained a visits_deleted count in 0.42.0; since 0.46.0 it sums both sources above, and the command's message says so ("past monitor.visits_retention_days" when --only-blocked was passed, "past monitor.visits_retention_days and/or --older-than-days" otherwise).

New in 0.60.0: monitor.visits_retention_days now also deletes monitor_page_hits rows whose day is older than the cutoff — same gate (0 = disabled, the default), same call inside DataPruner::prune(). Before this, a visitor's page-hit profile had no retention of its own: visits_retention_days cleaned up the visit journey in monitor_visits, but the per-path hit counts behind it in monitor_page_hits lived forever, even for the anonymous tracker (bots/API, no session — it never creates a monitor_visits row for this setting to clean up in the first place, so this was its only route to any retention at all). monitor:prune's output and DataPruner::prune()'s return value gain a separate page_hits_deleted count (not folded into visits_deleted — it isn't a visit).

Visits (monitor_visits, since 0.42.0)

Where each piece of tracking data lives:

Data Lives in
Remember-me token (the cookie value) monitors.id_token (unique index)
Device data: ua, user_id, flags, your own tag() data monitors.data (JSON)
Hits per path (+ not_found) monitor_page_hits
IPs seen per device monitor_visit_ips
The journey of each visit monitor_visits
The IP that opened each visit monitor_visits.ip (since 0.46.0)

Until 0.41.0, page/not_found/ips/sessions/visits were written into the JSON blob and copied to the child tables by a model hook. From 0.42.0 the child tables are the source of truth, written directly by the trackers (Monitor::recordHit() is an atomic hits = hits + 1 upsert; Monitor::recordIp() an insertOrIgnore), and the blob no longer carries those keys — reading $monitor->data['page'] etc. now returns nothing.

monitor_visits has one row per visit = one PHP session: paths is the list of paths hit during the visit in access order, raw and without counts (consecutive repeats such as a page reload are kept — they're diagnostic signal; collapse them when analysing, not when storing), created_at is the start of the visit, updated_at is its last activity (the server can't know when the user actually left), and scraper turns true as soon as any request of the visit was flagged and never goes back — the visit is still recorded, just marked, so funnel analyses can filter WHERE scraper = false.

The visit is keyed by a monitor_visit_id stored inside the session, not by the session id: Laravel regenerates the session id on login, which would otherwise split a login → dashboard journey in two. A genuinely new session has no monitor_visit_id and starts a new visit. Only the tracker with a session creates visits; the anonymous one (API/bots/scrapers, no session) never does. Every tracked request is added, including AJAX/API calls inside the web group — call Monitor::skipTracking() for the ones that shouldn't show up in the journey.

ip (since 0.46.0): the IP that opened the visit, written only when the monitor_visits row is created (Support\VisitRecorder::record()) — never rewritten on later requests of the same visit, even if the visitor's IP changes mid-session (that's tracked separately, and independently, by monitor_visit_ips/SessionVisitorTracker::recordIpIfChanged()). Nullable: a visit recorded before this column existed keeps ip = NULL forever — there's no reliable per-visit IP to backfill it from (only monitor_visit_ips, which doesn't record which IP opened which visit, existed before). Exposed by getMonitorVisits above.

Config (all in config/monitor.php; monitor:update adds missing keys):

  • track_visits (default true): journey + IP + a 5-year cookie is personal data — turn it off if your privacy policy doesn't cover it.
  • visit_max_paths (default 200): once a visit reaches this many paths it only bumps updated_at.
  • visits_retention_days (default 0 since 0.46.0, was 90): see "Visit retention" above, including the 0.46.0 behavior change.

Monitor::skipTracking() is unchanged. A concurrent pair of requests from the same session can, rarely, drop one step of a journey (the visit row is read-modify-written); hit counts are unaffected (atomic).

API route tracking (MonitorApiMethod, since 0.75.0)

Until 0.75.0, requests in Laravel's api route-group middleware — e.g. a consumer app's Sanctum-authenticated API routes — were completely invisible to this package: no tracking, no IP/path block enforcement. The package now self-registers a second middleware, MonitorApiMethod, onto the api group (appendMiddlewareToGroup, same mechanism MonitorMethod already uses for web) — nothing to wire up in your own bootstrap/app.php/Kernel.

Grouping: a request carrying a valid Sanctum personal access token is grouped into one Monitor per token (personal_access_tokens.id), not per user — two tokens of the same user (e.g. a mobile app and a server integration) are two separate Monitor rows, each with data.user_id set to the token's owner. A request with no token (or an invalid one) falls back to the same IP-based grouping the web group's anonymous tracker already uses (AnonymousVisitorTracker). The token is resolved directly via PersonalAccessToken::findToken() — it does not depend on the matched route declaring auth:sanctum, so an API route that only optionally accepts a token is still grouped correctly when one is present.

Classification: a token-grouped Monitor is created already classified — kind = 'bot', source = 'auth' (protected against a later source=ai overwrite, same rule as any other protected source — see "IP classification" below), tags user + api. It never becomes human, and shows up in the "clean bots" queue (getMonitorQueue(group=clean_bots)) like any other bot.

Visits: since this group is stateless (no session to track "which visit is still open"), the visit boundary is time-based instead: a new row in monitor_visits starts when there has been no call from the same token/IP for more than config('monitor.api_visit_gap_minutes') minutes (default 30); a call inside that window appends to the latest visit of that same Monitor instead (same visit_max_paths cap as the web group). Both are recorded the same way VisitRecorder already does for session-based visits.

IP blocking still applies even with a valid token: if the request's IP is on the blocklist, the response is a clear JSON 403 ({"success": false, "message": "Blocked"}) — never the HTML block page the web group's middleware produces. When this happens and the request carried a valid token, an event ("request bloqueada com token válido") is additionally recorded in monitor_api_block_events (ip, token id/name, user id) — queryable via the new getApiBlockEvents action (same read-token auth as getBlockResults), for a future dashboard to surface "someone with valid credentials is calling from a blocked IP".

An invalid token (present but rejected by PersonalAccessToken::findToken(), including an expired one) is tracked by IP and counts as a new scraper signal (ScraperSignalDetector::SIGNAL_INVALID_API_TOKEN) — a request with no Authorization header at all never gets this signal, only one that actively presented a bad token.

getUserMonitors/getIpMonitors/getMonitorQueue now also expose, per row: is_api_monitor (bool) and api_token_name (the Sanctum token's name, or null for a non-API Monitor or an orphaned token_id).

laravel/sanctum itself is an optional dependency — see "Sanctum (optional)" above.

Advanced usage

Ignoring IPs (monitor.ignore_ips, since 0.43.0)

Requests from IPs listed in config('monitor.ignore_ips') pass straight through MonitorMethod: no Monitor row, no visit, no monitor_ip_stats entry, no scraper signal, and never blocked — not by IP, not by a honeypot path. The check is the first thing the middleware does, an in-memory comparison before any database access, so it costs nothing per request (it actually saves work). Entries are exact IPs or CIDR ranges, IPv4 or IPv6. In .env, comma-separated:

MONITOR_IGNORE_IPS=203.0.113.7,127.0.0.1,::1,10.0.0.0/8

Built for the server's own traffic — a cron health check doing curl https://your-site every minute has no cookie, so it used to create one Monitor + one visit per run, inflate monitor_ip_stats (and flag itself as a scraper) — and for trusted monitoring/admin IPs. Default is empty (nobody is ignored). Requests from an ignored IP aren't tracked retroactively: existing rows stay until pruned.

⚠️ Only list IPs that can't be forged. Behind a reverse proxy with an open trustProxies ('*'), a forged X-Forwarded-For would make a request look like it came from an ignored IP and escape the monitor. Also note a cloud instance's public IP can change on stop/start unless it's an Elastic/static IP — when it changes, the ignore silently stops matching (the noise comes back, nothing breaks).

Skipping tracking for a request

MonitorMethod runs on every request in the web middleware group, so any AJAX/API-style endpoint inside that group (a language switcher, a form submit, etc.) gets counted as a page view and can overwrite the current Monitor record's data with values that don't belong to a real page visit. Call Monitor::skipTracking() before returning the response for any request that shouldn't be tracked:

use Drcantagalo\LaravelMonitor\Facades\Monitor;

Route::post('/lang/{locale}', function (string $locale) {
    Monitor::skipTracking();

    // ... switch locale ...

    return back();
});

Under the hood this just sets a session flag; SessionVisitorTracker reads and clears it the next time MonitorMethod processes this session, skipping its tracking logic for that one request. The session key used is config('monitor.skip_session_key') (default avoid_monitor) — publish the package config (monitor-config tag) to change it.

Permanently excluding a route from tracking (avoid-monitor, since 0.45.0)

Monitor::skipTracking() above is circumstantial — it has to be called explicitly inside the controller/action handling the request, which is easy to forget on a new route (that's exactly how it happens: a route gets added, nobody remembers to call the facade inside it, and it starts polluting the journey/data until someone notices). For a route or group that should never be tracked — automatic polling, internal dedicated endpoints — put that decision in the route definition itself instead of depending on someone remembering the facade call inside it:

use Illuminate\Support\Facades\Route;

Route::middleware('avoid-monitor')->group(function () {
    Route::get('/dashboard/{installation}/triage-status', TriageStatusController::class);
});

Use whichever mechanism fits the shape of the decision:

  • Monitor::skipTracking() — a request should be skipped conditionally, decided by something the controller checks at runtime (e.g. an endpoint shared by several callers that only skips tracking when nothing actually changed).
  • avoid-monitor middleware — a route should never be tracked, full stop, independent of anything the controller does.

The middleware just calls Monitor::skipTracking() for you, before $next($request) runs — same session flag, same mechanism, no separate code path to keep in sync. Combining both on the same route (middleware present and a manual skipTracking() call inside the action) is harmless: the second call just re-sets the same flag the first one already set.

updateRules (reserved, not implemented yet)

The handler action updateRules exists and is routed (same auth as updateBlockedIps), but it's currently a stub — it always responds {"success": true, "message": "Monitoring rules updated (stub)"} without reading its input or changing any behavior. Don't build against it as a real feature yet.