drcantagalo / laravel-monitor
A lightweight Laravel package providing CRM tools, access monitoring, and anti-scraper protection. Requires Laravel's Cache facade (any driver).
Requires
- php: >=8.1
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.32
- laravel/sanctum: ^4.0
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.76.0
- v0.74.0
- v0.73.1
- v0.73.0
- v0.72.0
- v0.71.1
- v0.71.0
- v0.70.0
- v0.69.0
- v0.68.0
- v0.67.0
- v0.66.0
- v0.65.0
- v0.64.0
- v0.63.0
- v0.62.0
- v0.61.1
- v0.61.0
- v0.60.1
- v0.60.0
- v0.59.1
- v0.59.0
- v0.58.0
- v0.57.0
- v0.56.0
- v0.55.0
- v0.54.0
- v0.53.1
- v0.53.0
- v0.52.0
- v0.51.0
- v0.50.0
- v0.49.0
- v0.48.0
- v0.47.0
- v0.46.0
- v0.45.0
- v0.44.0
- v0.43.0
- v0.42.0
- v0.41.0
- v0.40.0
- v0.39.0
- v0.38.0
- v0.37.0
- v0.36.0
- v0.35.0
- v0.34.0
- v0.33.0
- v0.31.0
- v0.30.0
- v0.29.0
- v0.28.0
- v0.26.1
- v0.26.0
- v0.25.0
- v0.24.0
- v0.23.0
- v0.22.0
- v0.20.3
- v0.20.2
- v0.20.1
- v0.20.0
- v0.19.0
- v0.18.0
- v0.17.0
- v0.15.1
- v0.15.0
- v0.14.0
- v0.13.0
- v0.12.0
- v0.11.0
- v0.9.0
- v0.7.0
- v0.4.0
- v0.3.0
- v0.2.1
- v0.2.0
- v0.1.32
- v0.1.31
- v0.1.30
- v0.1.29
- v0.1.28
- v0.1.27
- v0.1.26
- v0.1.25
- v0.1.24
- v0.1.23
- v0.1.22
- v0.1.21
- v0.1.20
- v0.1.19
- v0.1.18
- v0.1.17
- v0.1.16
- v0.1.15
- v0.1.14
- v0.1.13
- v0.1.12
- v0.1.10
- v0.1.9
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
This package is auto-updated.
Last update: 2026-10-21 10:47:48 UTC
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. SeeCHANGELOG.mdfor 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_originor 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
versionkey to the actually-installed version (read from Composer, not from the package template), soconfig('monitor.version')reported bymonitor: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: totalMonitorrows — one row per recognized device/browser (see "Remember-me" above). Since0.56.0it is the sum ofmonitors_by_kind(same cached snapshot, excluding the legacyhumankey — see below), so the two always agree exactly.visits_total:COUNT(*)ofmonitor_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 since0.42.0(until0.41.0this was aSUMofdata.visitsover the JSON blob, which is no longer written) and stays0withmonitor.track_visitsturned off.sessions_total: removed in0.56.0(breaking). Since0.42.0a visit is a PHP session, so it was always the same number asvisits_total— readvisits_totalinstead.monitors_by_kind(since0.56.0, categories expanded in0.59.0): everyMonitorsplit into disjoint groups — "the Monitor is the source of truth" for the dashboard header.human_guest/human_user(since0.59.0): Monitors withkind=human, split by whetherdata.user_idis present (see "Authenticated user tagging" below).humanis kept ashuman_guest + human_user, for consumers still reading the old key — excluded from the sum that must equalvisitors_total(it's redundant with the two granular keys, summing it too would double-count every human Monitor).bot: Monitors withkind=bot(anysource— manual, AI, or theauthsource doesn't apply here, only humans get auto-tagged).flagged(new in0.59.0): Monitors with nokindthat were seen at a flagged or currently-blocked IP — same exclusion criteriagetMonitorQueueCountsalready used internally. A Monitor's ownkindalways wins: a classifiedhuman/botseen at a flagged IP still counts by its classification, neverflagged.new/unclassified: Monitors with nokindand notflagged, split bycreated_atagainst the sameai_triage_min_age_hourscutoff the AI triage queue uses (see "IP classification" below) —newis younger than the cutoff,unclassifiedis at or past it. Since0.59.0these two no longer includeflaggedMonitors (before, they did) — this is whymonitors_by_kindnow matchesgetMonitorQueueCountsexactly, not just approximately.human_guest + human_user + bot + flagged + unclassified + newalways sums to exactlyvisitors_total.- Computed in a single aggregate query (left join on
monitor_labels, conditionalSUM(CASE …), noGROUP BY— the flagged/user-id breakdowns are expressed asid IN (...)against two ID sets computed once beforehand, not a second join). On an install not yet migrated to0.53.0+ (nomonitor_labelstable) it fails open by counting every Monitor as having nokind.
unique_ips_total:IpStat::count()— reusesmonitor_ip_stats(see "Per-IP stats" below), which already keeps exactly one row per unique IP ever seen. Deliberately not a dedupe ofmonitor_visit_ipsacross everyMonitor.blocked_attempts_total: unchanged, see "Blocked-attempt counter" below.package_version(since0.46.0): the actually-installed package version, read fromComposer\InstalledVersions::getPrettyVersion( 'drcantagalo/laravel-monitor')— not fromconfig('monitor.version'). Deliberately not the config value:config/monitor.phpis 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 timemonitor:install/monitor:updatewrote it — it drifts from the real installed version the moment the package is updated without runningmonitor:updateright after (same problemMonitorUpdateCommandalready solves for that specific key by readingInstalledVersionsitself, verMonitorUpdateCommand).getDataalways reflects what Composer actually installed.nullif the package somehow isn't registered in Composer'sinstalled.php(e.g. a manual symlink outside the normal Composer flow) — fails open, never breaksgetData.config_version(since0.47.0):config('monitor.version')— the frozen value from the host project's publishedconfig/monitor.php, i.e. whatevermonitor:install/monitor:updatelast wrote into it (seeMonitorUpdateCommand). Sent deliberately alongsidepackage_version, not instead of it: the two keys diverging (package_versionahead ofconfig_version) is exactly the signal that the client rancomposer updatebut never followed up withphp artisan monitor:update— which means both the published config and the package's pending migrations are out of date (seeMonitorUpdateCommand::confirmPendingMigrations()). Clients on a package version older than0.47.0don't send this key at all.visitors_total/visits_total/unique_ips_total(added in0.10.0) andmonitors_by_kind(0.56.0) share a short, fixed cache TTL (config('monitor.data_totals_cache_ttl_seconds'), default45seconds — same rationale asblock_results_cache_ttl_seconds) and fail open to0if the underlying table/column isn't there yet on an older, not-yet-migrated install (logged viaLog::warning).package_version/config_versionare not cached (plain in-memory lookups, no I/O) andblocked_attempts_totalhas 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-meroute was removed — the package no longer opens an HTTP route for this without the host app's explicit awareness. UseMonitor::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'), defaultmonitor_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 fromdocument.cookie; the browser just carries it back automatically on every request). -
Automatic reconnection (since v0.1.22):
MonitorMethodalso checks themonitor_id_tokencookie 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-newMonitor(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 matchingMonitorrow. Returnstruewhen a matching visitor was found (and merged into the current PHP session),falsewhen 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()setssession(['remember_me' => $token]); theMonitorMethodmiddleware 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-dataroute was removed — same rationale asremember-meabove. UseMonitor::tag()below instead.
-
Monitor::tag(array $data): bool. Call it server-side. Requires an active monitor session (i.e.MonitorMethodmust have already run at least once for this visitor — same precondition asrecognize()). Returnstrueon success;falsewhen there's no active monitor session yet, or when$datais 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 byMonitorMethod/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 writesdata['user_id'](Auth::id()) onto the current device's row, alongsideua/ips/page. It never merges or reassigns rows byuser_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) whereAuth::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(defaulttrue). Set tofalseto opt out — e.g. host apps withoutAuthconfigured, or that don't want this data for privacy-policy reasons. - Protected key: like
sessions/ips/visits/page/id-token/ua,user_idis inDrcantagalo\LaravelMonitor\Support\Monitor::PROTECTED_DATA_KEYS— a call toMonitor::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 timeuser_idis recorded for a Monitor (the guest → authenticated transition — not on every subsequent authenticated request, see below), the package also ensures amonitor_labelsrow withkind=human,source=auth(a newsourcevalue, alongsideai/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 alreadykind=bot(evensource=manual, e.g. a known bot account that happens to log in) or alreadykind=humankeeps it as-is — only a Monitor with nokindyet gets tagged. Gated by the sametrack_authenticated_userconfig above; disabling it skips this too.- Cheap by design: the check only runs on the request where
user_idis being written for the first time (nothing indatayet) — 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_idrecorded before upgrading to0.59.0never go through that first-time transition again, so a migration backfills them once (same rule: only Monitors with nokindyet getkind=human, source=auth). - See "Aggregated dashboard totals" above for how this interacts with
monitors_by_kind.human_user, and "Daily timeline" below forseries.monitors_new.human_user/series.visits.human_user.
- Cheap by design: the check only runs on the request where
- Reserved
usertag (since0.61.0): the same guest → authenticated transition above also ensures the Monitor'smonitor_labels.tagscontainsuser(MonitorLabel::TAG_USER) — independent of, and never gated by, thekindprotection described above: a Monitor already manually classifiedbotthat logs in still gains theusertag (the tag only means "this Monitor is tied to auser_id", it says nothing about the bot/human judgment). This tag is entirely managed by the package —setMonitorTagsrejects (422) any manualop=add/op=removecall withtag=user; there's no way to add or remove it by hand. Backfill: same mechanics as thekind=humanbackfill above — a migration adds the tag to every Monitor that already haduser_idbefore upgrading to0.61.0and 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 carriesuser) from guests (none do) via the existingtagfilter — 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), notMonitor::where('data->user_id', $id). This is not just a style preference:where('data->user_id', $id)compiles tojson_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 viaEXPLAINon 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
$idto a string:monitors_user_idisVARCHAR. Comparing it against a native PHP int through PDO (e.g.Auth::id(), which is an int) makes MySQL list the index underpossible_keysbut not actually use it (key: NULL) — an implicit type-conversion cost, also confirmed viaEXPLAIN.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 whenSchema::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 towhere('data->user_id', $id)(no index, full scan, but correct) instead of erroring on the missing generated column — note this fallback does not cast$idto string like the MySQL path does: SQLite'sjson_extractreturns the JSON value in its native storage type (e.g. an integer for{"user_id": 42}), and'42'(text) never equals42(integer) there, so the fallback must compare against the same type$idwas passed in as (in practice always an int, fromAuth::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 peruser_idseen indata['user_id'](see "Authenticated user tagging" above; rows without auser_idare excluded). Params:page(default1),per_page(default25, max100). 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 bylast_activitydescending. Aggregation (COUNTof visits/MAX(monitors.updated_at)) and pagination run in SQL, grouped by the same indexed generated columnMonitor::forUserId()uses on MySQL (monitors_user_id) — never the rawdata->user_idexpression, for the same index-matching reason documented above.visits_countcountsmonitor_visitsrows peruser_id(aLEFT JOIN, so aMonitorwith no visit recorded yet still counts as0) instead of countingMonitorrows. AMonitorrow 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 give1for a user who only ever visits from the same browser. (0.12.0–0.41.0summeddata.visitsfrom the JSON blob instead, which is no longer written.)
getUserMonitors(since0.48.0, replacinggetUserVisits— see "⚠️ Breaking" in CHANGELOG[0.48.0]): given auser_id({"success": false, "message": "user_id is required"},422, if missing/empty), a paginated listing of that user'sMonitorrows (devices/browsers), viaMonitor::forUserId($id)— the exact same response shape asgetIpMonitorsbelow (id,created_at,updated_at,datasanitized,ips,visits_count), sharing itshydrateMonitorRowshelper. Params:page(default1),per_page(default20, max100). Ordered byupdated_atdescending,iddescending as a deterministic tiebreaker — same pattern asgetIpMonitors. From here, the dashboard navigates user → Monitors → a specific Monitor's full visit history (getMonitorVisits), the same waygetIpMonitorsalready lets it navigate from an IP.user_idis cast tointbefore callingMonitor::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
getUserVisitsaction it replaces, this does not rebuilddata.page/data.ipsor attach each row's last 20 visits — it returns the same lean shape asgetIpMonitors(ips,visits_count), leaving the full per-Monitor journey togetMonitorVisitson 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'suserstable (arbitrary schema, out of scope for a host-agnostic package). They only appear when the host app already calledMonitor::tag(['name' => $user->name, 'email' => $user->email])(see "Arbitrary visitor data" above;name/emailare not inPROTECTED_DATA_KEYS) somewhere in its own request lifecycle, e.g. right after login. Without that call, both come backnulland the dashboard falls back to displaying the rawuser_id.- Stable across a user's Monitors (since
0.57.0):tag()only writesname/emailonto the Monitor row active at that exact moment (the one handling the login request), whileuser_idis stamped onto every authenticated request (SessionVisitorTracker). A user who switches device/browser, or whose session is inherited without going through login again, gets additionalMonitorrows withuser_idbut noname/email. SogetUsers, and anywhere a listing exposesuser_id(getUserMonitors/getIpMonitors/getMonitorQueuebelow, via the sharedhydrateMonitorRows), resolvenameandemailindependently of each other — each is read off the most recently updatedMonitorrow of thatuser_idthat actually has that field set (not necessarily the same row for both, and not necessarily the row being listed).nullonly when noMonitorof that user ever had the field. Resolved inMonitorController:: resolveUserContacts()with one batched query per field for the whole page (whereInover the page'suser_ids), never a per-row/per-user lookup.
- Stable across a user's Monitors (since
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 forconfig('monitor.read_token_ttl_minutes')minutes (default 15), and returns{"success": true, "token": "...", "expires_at": "..."}.- The token returned by
issueReadTokenis 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/getMonitorVisitssince0.46.0,getUserMonitorssince0.48.0,getIpTagssince0.49.0,getTableStatssince0.50.0,getAccessLogsince0.51.0,getMonitorQueue/getMonitorQueueCountssince0.53.0,getConfigsince0.54.0,getTimelinesince0.58.0).clearData,previewClearData(since0.63.0— read-only, but kept on the samelocal_token-only footing asclearData/pruneDatarather than the ephemeral-read-token list, since it's part of the same admin cleanup flow),pruneData,updateBlockedIps,unblockIp,flagScraperPath,unflagPath,updateRules, andissueReadTokenitself always require the permanentlocal_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'sconfig/cors.php, since every client site has a different Laravel install. The allowed origin isconfig('monitor.dashboard_origin'), defaulting tohttps://monitor.cantagalo.it(no manual configuration required). PreflightOPTIONSrequests get a204with 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 thewebmiddleware group to catch genuinely nonexistent paths.MonitorMethodonly 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 thewebgroup 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 toroutes/web.phpcloses the gap for completely unknown paths too — but the closure must return a real404HTTP 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-hit404(see "Honeypot hits" below) is actually indistinguishable from a genuine one.MonitorMethod's ownabort(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 thrown404— aresources/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 status404, reopening the same tell this feature exists to close. Stick to one of the two patterns above.
-
flagScraperPath(Authorization: Bearer <local_token>, same auth asupdateBlockedIps/clearData— never accepted with the ephemeral read token):POST /monitor/handler?action=flagScraperPathwith{"path": "wp-admin/install.php"}(host-less; a leading/is stripped if present). Two things happen:- The path's row in
monitor_pathsgetsstatus: 'trap'(updateOrCreate, since0.20.0— overwrites a'safe'status if the path was previously marked safe viamarkPathSafe, making flagging and marking safe mutually exclusive; see "Path review state" below). From then on,MonitorMethodrejects 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, inmonitor_page_hits.path). Since0.39.0, the rejection is404(indistinguishable from any other nonexistent route) on the first hit from an IP not yet blocked, and403from then on once that IP is itself inmonitor_blocked_ips— see "Honeypot hits" below for why. - Every IP already recorded (
monitor_visit_ips) against aMonitorthat visited that path is blocked inmonitor_blocked_ips(source: 'scraper-path'), same mechanism asupdateBlockedIps.
- Response:
{"success": true, "path": "...", "blocked_ips": [...]}, or{"success": false, "message": "No path provided"}(422) ifpathis missing/empty. - Guard since
0.21.0: before either step runs, the sameMonitorscan used to find IPs to block also checks whether the path already resolved as a real, non-404 page in some host (data.not_foundempty/false for a matching key). If so, the call is refused — nothing is written tomonitor_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: theMonitortable scan for step 2 used to load every row into memory at once (Monitor::all()), same class of bug asgetDatain0.10.0above — confirmed exhausting PHP-FPM'smemory_limitin production at ~31.7k rows, returning a bare 500 with no body. Now usesMonitor::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 wholemonitorstable on every call (same bug class fixed forgetPages/getVisitorPaths/PathsAuditor::audit()in0.23.0-0.25.0, now on the write side — confirmed timing out in production at ~41kMonitorrows). Both the live-route guard and the IP lookup now read frommonitor_page_hits(0.23.0): the guard is a suffix-index lookup againstWHERE not_found = false(same technique asPathsAuditor::liveTrafficSuffixIndex()), and the IP scan resolves the small set of matchingmonitor_page_hitsrows (WHERE not_found = true) first, then readsmonitor_visit_ipsonly for those specificmonitor_ids (until0.41.0this decodedMonitor.data.ips). Same matching logic, no response shape change.
- The path's row in
-
flagScraperPaths(same auth asflagScraperPath, since0.19.0): batch version —POST /monitor/handler?action=flagScraperPathswith{"paths": ["wp-admin/install.php", ".env", ...]}. Same effect as callingflagScraperPathonce 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: theMonitortable 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) ifpathsis missing/empty or every entry was invalid.- Guard since
0.21.0: same live-route check asflagScraperPath, applied per path — a path that resolves as a real route on some host is rejected and reported underrejected(with why), without aborting the rest of the batch.rejectedis always present (empty array when nothing was refused). - Fixed in
0.26.0: same fix asflagScraperPathabove, but it matters even more here — the whole-table scan used to happen once per batch (stillO(monitors rows), notO(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. SameresolveScraperPathTargets()helper as the singular version, called once for the whole batch.
- Guard since
-
unflagPath(same auth asflagScraperPath): reverts it —POST /monitor/handler?action=unflagPathwith{"path": "wp-admin/install.php"}removes the path'sstatus: 'trap'row frommonitor_pathsand clears the correspondingMonitorMethod::isPathBlocked()cache entry immediately. Response:{"success": true, "path": "...", "was_flagged": true|false}(falsewhen the path wasn't flagged to begin with — not an error). Does not unblock the IPsflagScraperPathmay have blocked because of that path — useunblockIpfor those individually. -
markPathSafe(same auth asflagScraperPath, since0.4.0):POST /monitor/handler?action=markPathSafewith{"path": "old-campaign-link"}(host-less, same convention asflagScraperPath) records the path inmonitor_pathswithstatus: 'safe'andreviewed_at: now()(updateOrCreate, since0.20.0— overwrites a'trap'status if the path was previously flagged viaflagScraperPath, making the two mutually exclusive). Purely a review-state flag — unlikeflagScraperPath, it blocks nothing; it just removes the path fromgetPages'pending_reviewqueue (see below) once a human has confirmed a recurring404isn'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) ifpathis missing/empty.- Guard since
0.21.0: only writesstatus: 'safe'when at least one hit withdata.not_found = trueexists 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-decodeMonitor.data.not_foundrow by row (Monitor::cursor()) — same bug class fixed forgetPages/getVisitorPaths/PathsAuditor::audit()in0.23.0-0.25.0, now on the write side. Now a suffix-index lookup againstmonitor_page_hits WHERE not_found = true(same technique asPathsAuditor::liveTrafficSuffixIndex()), noMonitorscan at all. Same guard behavior, no response shape change.
- Guard since
-
markPathsSafe(same auth asflagScraperPath, since0.19.0): batch version ofmarkPathSafe—POST /monitor/handler?action=markPathsSafewith{"paths": ["old-campaign-link", ...]}. Same rationale asflagScraperPaths: avoids one HTTP request per path when clearing many entries from thepending_reviewqueue 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 asmarkPathSafe, applied per path — a path with no recorded 404 anywhere is rejected and reported underrejected, without aborting the rest of the batch. - Fixed in
0.26.0: same fix asmarkPathSafeabove — the per-batchMonitorscan (stillO(monitors rows)even though it checked every path in the batch in one pass) is now a single suffix- index lookup againstmonitor_page_hits, built once for the whole batch.
- Guard since
-
unmarkPathSafe(same auth): reverts it —POST /monitor/handler?action=unmarkPathSafewith{"path": "old-campaign-link"}deletes the path'sstatus: 'safe'row frommonitor_paths, so the path goes back to the defaultpendingstate. Response:{"success": true, "path": "...", "was_safe": true|false}(falsewhen the path wasn't marked safe to begin with — not an error).
Path review state (
monitor_paths): since0.20.0, a path'strap/safestatus lives in a single table, one row per path (no row =pending, the default). Before0.20.0these were two independent tables (monitor_blocked_pathsfortrap,monitor_path_reviewsforsafe) 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 priorsafestatus and vice versa. See CHANGELOG[0.20.0]for the migration that merges the old tables (existing data included; a conflicting path is resolved totrap, logged viaLog::warningduring 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 asclearData/flagScraperPath/updateRules/issueReadToken; the ephemeral read token fromissueReadTokenis never accepted here). -
Request body:
{"ips": ["203.0.113.7", "198.51.100.42"]}. Each entry is validated withfilter_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) whenipsis missing/empty/not an array;{"success": false, "message": "No valid IPs provided"}(422) when every entry failed validation. -
Persisted in
monitor_blocked_ipswithsource: '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 asupdateBlockedIps): reverts it (and any automatic block on that IP) —POST /monitor/handler?action=unblockIpwith{"ip": "203.0.113.7"}removes the IP frommonitor_blocked_ips(whatever itssource) and clears the block-check cache immediately. Response:{"success": true, "ip": "...", "was_blocked": true|false}(falsewhen the IP wasn't blocked to begin with — not an error), or{"success": false, "message": "No valid IP provided"}(422) ifipis missing/invalid. UnlikeupdateBlockedIps,unblockIpdoes not go throughScraperBlocker— it deletes themonitor_blocked_ipsrow outright, resettingstrike_count/lifetime_offense_countcompletely. 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_countboth start at1, block lasts2^1 = 2hours. -
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_countincrements 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_atandsourcestay 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. Before0.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.flagScraperPathsnow also registers a single offense per IP for the whole batch. -
Decay: every
config('monitor.auto_block_strike_decay_cooldown_days')(default30) days that pass without a new offense from that IP,strike_countdrops 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_countis never touched by decay. -
Permanent promotion: once
lifetime_offense_countreachesconfig('monitor.auto_block_permanent_after_lifetime_offenses')(default10),blocked_untilis set tonull(permanent) instead of a new expiry — regardless of whatstrike_countcurrently is. -
sourceis 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/AnonymousVisitorTrackeralready runScraperSignalDetectoron every tracked request to decidedata.flags.scraper. When the number of signals triggered on a single request reachesconfig('monitor.auto_block_signal_threshold')(default3, higher thanscraper_signal_threshold's default of2on 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 onlySessionVisitorTrackerwould 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, since0.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. Before0.16.0this calledBlockedIp::firstOrCreate()directly (permanent, static block from the first hit); it now goes through the same escalating/expiring mechanism as every other automatic block.404, not403, on that first hit (since0.39.0): a403response is a tell — it lets a scanner tell the monitored honeypot path apart from every other nonexistent path, which returns404, turning the very defense meant to be invisible into a trivial oracle for finding it. The rejection is nowabort(404)on the request that registers the offense (the offense itself, and themonitor_block_resultscounter below, still happen exactly as before — only the HTTP status changes), and stays403from the second hit onward, once the IP is itself inmonitor_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 shows403on 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_untilis notnull(a permanent block is never touched).blocked_untilis already in the past (expired).config('monitor.auto_block_strike_decay_cooldown_days')has already passed sincelast_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, generatingON DUPLICATE KEY UPDATE/ON CONFLICTas appropriate for the active driver), not afirstOrCreate+incrementpair — 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 ofMonitorMethod— ifmonitor_block_resultshasn't been migrated yet in some environment, the increment is skipped (logged viaLog::warning) and the request is still blocked (abort()runs unconditionally, outside the try/catch, with the403/404status decided as described above). blocked_attempts_total: a new field on the existinggetDataresponse (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 asgetVisitorsByIp/getBlockedIps— permanentlocal_tokenor the ephemeral read token) —GET /monitor/handler?action=getBlockResults, paramspage(default1),per_page(default20, max100). Response:{"success": true, "data": [{"ip": "203.0.113.7", "counter": 42}, ...], "meta": {"page", "per_page", "total", "last_page"}}, ordered bycounterdescending (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_totalandgetBlockResultsuse a short, fixed TTL (config('monitor.block_results_cache_ttl_seconds'), default45seconds) — deliberately not the versioned cache scheme shared bygetPages/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:apacheornginx. If omitted, falls back toconfig('monitor.denylist_format')(defaultapache).- Output path:
config('monitor.denylist_path')(defaultstorage_path('app/monitor/denylist.conf')), directory created automatically if it doesn't exist yet. - Apache format: one
Require not ip x.x.x.xper line — meant to beIncluded 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 runnginx -s reloadafter 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-denylistby hand, whenever you want. -
exportDenylist(Authorization: Bearer <local_token>, same auth asupdateBlockedIps/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 usingconfig('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}, customwhere()constraints, etc.) resolve correctly since this reuses Symfony's own route compiler instead of reimplementing parsing. Route domain is deliberately ignored (mirrorsisPathBlocked()'s host-agnostic surface: "this route exists on whatever domain the installation serves"). Fallback routes (Route::fallback(...), commonly registered so honeypot paths reachMonitorMethodat all — see theroutes/web.phpnote 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 the0.21.0guard —data.not_foundempty/false for someMonitorhit whose key matches the path. Catches a resource that never goes through Laravel's router (a static file, etc.) thatroute_tablealone wouldn't see. Since0.25.0, this reads frommonitor_page_hits(0.23.0) via one SQL query plus an O(1) per-suffix set lookup, instead of decodingMonitor.datafor every row — see themonitor_page_hitsnote under Paginated page listing (getPages) below; before0.25.0this scanned the entireMonitortable in PHP on every call and could exceed a typical 10s HTTP timeout on installations with tens of thousands ofMonitorrows (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 asflagScraperPath/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 thanconfig('monitor.scraper_frequency_threshold')requests (default5) from the same IP withinconfig('monitor.scraper_frequency_window_seconds')seconds (default10).empty_user_agent: the request has noUser-Agentheader at all.known_bot_user_agent: theUser-Agentcontains (case-insensitive) any substring fromconfig('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 ofAccept,Accept-Language,Accept-Encodingare 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, reachingconfig('monitor.scraper_cumulative_visits_threshold'), default5000) 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, ornull(undefined). Set viasetMonitorKind(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 viasetMonitorTags(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 sincesetIpLabels(the only action that ever touchednote) was removed in0.61.0— an existingnoteis 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 editnoteper-Monitor, that's tracked as a follow-up (home-page 296); for now,noteis effectively read-only/legacy data.source:manual(default),ai(written by the AI triage flow; since0.61.0the AI flow has no bulk-by-IP write path either, so it must also classify oneMonitorat a time — theaivalue is still accepted onsetMonitorKind),auth(since0.59.0, written automatically the first time a Monitor authenticates — see "Authenticated user tagging" above), orverified/verified-{method}(since0.73.0, written byMonitor::markAsHuman()— see "Marking a visitor as human" below). Refers tokindspecifically, not to tags/note. Guaranteed by the package itself, not just the consumer: asource=aiwrite never overwrites akindalready set by ANY protected source on that sameMonitor—manual,auth, or anyverified*(MonitorLabel::isProtectedSource(), checked by bothsetMonitorKindandmarkAsHuman();maybeAutoHumanClassify()separately only ever fills in a Monitor with nokindyet, regardless of source). A manual write (setMonitorKindwithoutsource=ai) always wins and always recordssource=manual.classified_at: whenkindwas last (re)written for thatMonitor. 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(since0.53.0):monitor_id(422if missing/not an existingMonitor),kind(bot/human/null,422if anything else),source(manualdefault |ai, since0.62.0— any other value falls back tomanual). This is the Monitor detail view's classification control, and since0.61.0the only way to writekindat all. Asource=aiwrite is silently ignored (not applied) if the Monitor already has akindset with a protectedsource(manual,auth, or anyverified*— see thesourcefield note above and "Marking a visitor as human" below) — asource=manualwrite always applies, regardless of the Monitor's currentsource. Response:{"success": true, "monitor_id": 10, "kind": "bot", "applied": true}—applied: falsewhen the write was ignored by that protection (kindin the response is then the Monitor's unchanged current value, not the one submitted).setMonitorTags(since0.53.0):monitor_id(same validation assetMonitorKind),tag(single string, normalized;422if empty after normalization,422if it's the reservedusertag — see above — or*/-/any-foo, reserved for thetagfilter syntax, since0.72.0),op(adddefault |remove). Alwayssource=manualsemantics don't apply here — tags have nosourcetracking at all (see thesourcefield note above). Response:{"success": true, "monitor_id": 10, "tag": "amazon", "op": "add"}.
Read actions
getIpTags: every tag currently in use acrossmonitor_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(permanentlocal_tokenor the ephemeral read token). Response:{"success": true, "data": [{"tag": "amazon", "count": 12}, ...]}.
- 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
getMonitorQueue(since0.53.0): started as the AI-triage work queue, replacing the oldclean_ai_queuefilter ofgetVisitorsByIp(removed in that same version) — lists Monitors, not IPs, since classification belongs to theMonitor. Params:page/per_page(same asgetIpMonitors),group(required,422on anything outsidenew/unclassified/all/clean/clean_bots/clean_humans— including the oldrecheck, removed in0.55.0, see below). Since0.69.0:date_from/date_to(optional, filter by the Monitor's owncreated_at, same convention asgetVisitorsByIp) andtag(optional, filters bymonitor_labels.tags, same lowercase/trim normalization asgetVisitorsByIp'stagparam, including the*/-/-foospecial values since0.72.0— here they apply straight to the Monitor's own tags, not aggregated by IP) — all combinable with anygroupand with each other, and part of the cache key below.new/unclassified: Monitors with nokindat all (nomonitor_labelsrow, or one withkindnull), split only by age:new(created_atnewer thanai_triage_min_age_hours, config, default24— purely informational, never surfaced for triage) andunclassified(created_atat or past that threshold — the actual manual/AI triage backlog). Both exclude Monitors seen at an IP that's currentlyflagged(monitor_ip_stats.flagged) or actively blocked (BlockedIp::active()) — those already have their own work queue (filter=flagged/blockedongetVisitorsByIp), no need to also surface them here.all/clean/clean_bots/clean_humans(since0.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 withmonitor_ip_stats.flagged=true, nor at any currently-blocked IP (same exclusion set asnew/unclassifiedabove) — independent ofkind/age, so acleanMonitor can be bot, human, or unclassified. Blocked-IP exclusion here is a safety net only, not elaborate logic:DataPruneralready deletes Monitors at a blocked IP in batches, so they only exist briefly either way.clean_bots/clean_humans:cleanabove, further filtered bymonitor_labels.kind = 'bot'/'human'.all: every Monitor, no exclusion at all. Each row gains a read-only, display-onlyinherited_flaggedboolean:truewhen that Monitor has been seen at aflaggedIP (not blocked — a blocked IP's Monitors are already covered byblockedelsewhere 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 sharedhydrateMonitorRows, each row already carrying its ownkind/tags/note/source/classified_at— see "User listing" above), plusinherited_flaggedforgroup=all. Cached the same way asgetVisitorsByIp/getBlockedIps(see "Paginated visitor/blocklist listing" below).
getMonitorQueueCounts(since0.53.0):{"success": true, "new": 7, "unclassified": 12}— the same two groups asgetMonitorQueue, unpaginated counts only, feeding the triage modal in the dashboard. Since0.61.1: cached under a key that includes thenew/unclassifiedcutoff rounded to the minute (same technique asgetMonitorQueuebelow), so it can never drift fromgetMonitorQueue(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 tolistings_cache_ttl_minutesstale relative togetMonitorQueue'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(default24, replacesai_recheck_min_new_hits): aMonitorwith nokindstays innew(informational only, never surfaced for triage) while younger than this many hours, based oncreated_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 tounclassified, same as before. recheck_summary(the before/after hit breakdown eachrecheckrow used to carry) is gone with the group — there's nothing to summarize for a grace-period Monitor.- Manual classification (
setMonitorKind/setMonitorTags/setIpKindetc.) is not affected by the grace period — it has always worked, and keeps working, on any Monitor regardless of age. - No migration needed —
new/unclassifiedare both derived on the fly fromcreated_atand the absence ofkind, nothing is stored. Runphp artisan monitor:updateto 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:
utm_sourceon the entry URL (the market-standard convention, e.g.?utm_source=linkedin&utm_medium=social). A short alias,lm=, is accepted as a fallback whenutm_sourceis absent (never merged with it).- 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. Refererdomain, viaconfig('monitor.origin_referer_domains')(extensible, wildcards like"google.*"accepted): a mapped domain → its fixed source; aRefererpointing 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.bryields 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'sMonitor. 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 requiresession('monitor_id')to already exist.MonitorMethodonly creates/finds the visitor'sMonitorin its "return" phase (after the controller runs), so on a brand-new visitor's first request there is nomonitor_idin the session yet when the controller callsaddTag(). In that case the tag is queued as pending (kept in a request-scoped container binding, neverCache— that would leak the tag across visitors — and never the session, which is exactly what doesn't exist yet) and applied automatically right after theMonitorfor this same request is created or found, through the same sanitize/dedupe/MAX_TAGSpath described below.addTag()/addTags()still returntruein this case (the tag survived sanitization and will land on theMonitor), 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 theavoid-monitormiddleware,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 agetKey()method) or a raw user id, and applies the tag(s) to everyMonitorlinked to that user (the samedata.user_idlink the reservedusertag relies on — see "Authenticated user tagging" below). Returns how manyMonitors got tagged (0if 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 thePAYMENT.CAPTURE.COMPLETEDwebhook, 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 atMonitorLabel::MAX_TAG_LENGTH, and run throughMonitorLabel::normalizeTags()against the Monitor's existing tags (dedupe +MonitorLabel::MAX_TAGS). A tag that doesn't survive sanitization, that doesn't fit underMAX_TAGS, or that collides with the reservedusertag (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 asMonitor::tag()). -
A
MonitorLabelrow created with only a conversion tag (nokind, nonote) is still unclassified, same rule as visitor-origin tags above — classification is decided purely bykind, 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 callmarkAsHuman()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. Writeskind=human,classified_at=now()andsource='verified'(no$method) orsource='verified-{method}'($methodnormalized withStr::slug()— lowercased, restricted to[a-z0-9-]— and truncated to 15 characters, e.g.markAsHuman('Cloudflare Turnstile')recordssource=verified-cloudflare-turn). Returns whether the write actually applied, sameboolpattern astag()/recognize()/addTag()above.- Precedence —
manual>auth>verified-*>ai(MonitorLabel::isProtectedSource(), the same check that protectssetMonitorKindagainstsource=ai, see "IP classification" above):- If the Monitor's current
kindwas set with a protected source (manual,auth, or any existingverified*) this call is a silent no-op — it does not throw, does not changekind/source, and, crucially, does not touchclassified_ateither:classified_atalways stays at the time of the first accepted proof. CallingmarkAsHuman()again later with a different$methoddoes not "update" anything — the first call's method and date always win. - If the current
kindwas set withsource=ai(whetherhumanorbot), or the Monitor has no classification at all yet, the call applies normally. - A
monitor_labelsrow that only hastags/noteand nokindyet (e.g. created earlier byaddTag()) is treated the same as "no classification yet" — it is not protected, even though thesourcecolumn already carries the database's ownmanualdefault for an unrelated reason (that default is about the column, not about an actual manual classification — seeMonitorLabel:: isProtectedSource()'s docblock).
- If the Monitor's current
- Never required to run before
recognize()/tag(), but it does requiresession('monitor_id')to already exist — unlikeaddTag()/addTags(), a call before the current visitor'sMonitorexists is not queued as pending; it simply returnsfalse. - Doesn't touch
human_guest/human_user: those stay 100% derived fromdata.user_id(the reservedusertag — see "Authenticated user tagging" above), exactly as before.markAsHuman()never writes tags, onlykind/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(default1),per_page(default20, max100).filter:pending_review(default whenfilteris omitted:not_found = trueANDstatus != 'safe'ANDblocked = 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 hasstatus: 'trap'inmonitor_paths, matched by suffix the same wayflagScraperPathdoes),safe(since0.20.3: path hasstatus: 'safe'— see below). An unknownfiltervalue returns422.date_from/date_to(optional, any formatCarbonaccepts): since0.60.0, filters exactly by thedaycolumn ofmonitor_page_hits(a hit is only counted if it happened on a day inside the window). Before0.60.0this filtered by theMonitorrow'supdated_atinstead — "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; seeexact_sincebelow 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"}. Since0.60.0:exact_sinceis the date themonitor_page_hitsday-dimension migration ran on this installation (monitor_settings.page_hits_exact_since) —dayvalues from that date on were written directly byrecordHit()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.nullif 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 ofmonitor_ip_stats(one row per unique IP, maintained byIpStat::recordVisit()on every tracked request — see "Per-IP stats" above). Params:page(default1),per_page(default20, max100),filter(alldefault,flagged,clean,blocked, and since0.49.0the sub-filtersclean_bots,clean_humans,clean_unclassified— see "IP classification" above; an IP counts asblockedif it has an active row inmonitor_blocked_ips(BlockedIp::active(): permanent, or temporary and not yet expired); unknown value returns422),date_from/date_to(optional, filters by the row'slast_seen— "this IP was active in this window", same approximation asgetPages), since0.49.0tag/kind(optional, combinable with anyfilterabove — e.g.filter=blockedkind=botlists blocked bots), and since0.71.0include_without_monitors(boolean, defaultfalse— see below). Since0.72.0,tagaccepts three special values besides an exact tag name:*(the IP has at least one Monitor with some custom tag — any tag except the reserveduser),-(none of the IP's Monitors has one) and-foo(none of the IP's Monitors has the tagfoo— works forusertoo, e.g.tag=-userlists 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 in0.53.0:kind/tagsare now derived on the fly from everyMonitorever seen at that IP (kindisbot/humanonly if ALL of them agree,mixedif they don't,nullif none is classified;tagsis their normalized union) instead of being read from a per-IP row —note/source/classified_atare dropped from this response (they're per-Monitornow, with no single value that makes sense aggregated by IP — seegetIpMonitorsbelow for those), and a newcountsobject breaks down how many of that IP's Monitors fall in each kind. See "IP classification" above.- Regardless of which
filteris requested, results are always ordered withflagged = truerows first (the "possible scraper" work queue), falling back, since0.71.0, tovisits desc(see below — it used to bevisit_count desc, before that field was renamed/deprecated) within each group. Removed in0.29.0: between0.8.0and0.28.x, this also excluded/deprioritized IPs markedsafe— there is nosafestatus for IPs anymore (see "Per-IP stats" above), sofilter=flaggedand the default ordering now depend only onflagged. - Since
0.71.0:visit_count(the lifetime counter straight frommonitor_ip_stats, see "Per-IP stats" above — it's never decremented, including by aprune/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 intogetIpMonitorsfor 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-Monitorvisits_countthatgetIpMonitors/hydrateMonitorRowsalready expose (COUNTofmonitor_visits, derived from the IP's current Monitors viamonitor_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 oldmonitor_ip_stats.visit_count, just under a name that says what it actually is.visit_countitself stays in the response too, deprecated, with the exact same value aslifetime_visits— so consumers written against0.70.xand earlier don't break — and will be removed in a future major version.include_without_monitors(boolean, defaultfalse): an IP with amonitor_ip_statsrow but no current Monitor (exactly the post-prune case above) is excluded from this listing by default —visitswould always be0for it anyway, and there's nothing to drill into viagetIpMonitors. Passtrueto bring these back, withvisits: 0.meta.total/pagination reflect whichever set is in effect.
- Since
0.12.0:filter=flaggedalso excludes IPs already present inmonitor_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 underfilter=blocked, as before. - Since
0.33.0:filter=blockedno longer starts frommonitor_ip_stats— it queriesmonitor_blocked_ipsdirectly (the actual source of truth for whether an IP is blocked, same tableMonitorMethod::isBlocked()reads) and only optionally enriches each row withvisit_count/first_seen/last_seen/flagged/flagged_signalsfrommonitor_ip_statswhen that row still exists (null/falsewhen it doesn't — the IP still shows up either way). Fixes a real bug:monitor:prune --only-blocked --older-than-days=0running hourly (see below) deletes a blocked IP'smonitor_ip_statsrow 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_tonow filter bymonitor_blocked_ips.last_offense_atinstead oflast_seenunder this filter, and the response for each row also gainsblocked_until,strike_count,lifetime_offense_count,last_offense_atandsource(all frommonitor_blocked_ips) — the existing fields keep their shape, nothing is removed. - Since
0.71.0: this row also gainsvisits/lifetime_visits, same meaning and derivation as the default listing above (visitsfrom the IP's current Monitors,0if none;lifetime_visitsfrommonitor_ip_stats.visit_countwhen that row still exists,nullotherwise, same asvisit_count).include_without_monitorsdoesn'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 the0.33.0fix above. - Since
0.41.0:filter=blockedonly lists active blocks (BlockedIp::active()) — a temporary block whoseblocked_untilhas already expired no longer appears in this listing, same "blocked" definition used everywhere else in the package (theblockedfield onfilter=all/flagged/cleanrows since0.40.0, andMonitorMethod::isBlocked()). Before0.41.0this 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 inmonitor_blocked_ips(still feedsstrike_count/lifetime_offense_countfor the escalation ladder) — it just stops showing up here.
- Regardless of which
getVisitorPaths(since0.6.0): given anip({"success": false, "message": "No valid IP provided"},422, if missing/invalid), finds everyMonitorthat'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 everyMonitorrow to find which ones contain the IP (same bug class fixed forgetPagesin0.23.0— the cost used to be proportional to the size ofMonitor, not to that IP's activity). Amonitor_visit_ipstable (one row perMonitor+ip, kept in sync by the same model event asmonitor_page_hits) is looked up byipvia an index to get the relevantmonitor_ids, thenmonitor_page_hitssums hits per path for just those ids — both steps in SQL. Upgrading runs a one-time backfill migration populatingmonitor_visit_ips.
- Since
getIpMonitors(since0.46.0): given anip({"success": false, "message": "No valid IP provided"},422, if missing/invalid, same validation asgetVisitorPaths), a paginated listing of the actualMonitorrows (devices/browsers) ever seen from that IP — complementsgetVisitorPaths(which only aggregates paths, without exposing theMonitorrows themselves). Params:page(default1),per_page(default20, max100). Looked up the same way asgetVisitorPaths(monitor_visit_ips.ip, indexed — never a scan ofMonitor), ordered byupdated_atdescending (iddescending 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 asgetUserMonitors— see "Sanitizing thedatablob" below.ips: every IP thatMonitorhas ever been seen from (not just the one searched for), andvisits_count:COUNT(*)ofmonitor_visitsfor thatMonitor— both resolved with one groupedwhereInquery per page (not one query per row), same sharedhydrateMonitorRowshelpergetUserMonitors/getMonitorQueueuse.kind/tags/note/source/classified_at: thatMonitor's own classification (monitor_labels) — see "IP classification" above. Between0.49.0and0.52.0these lived per-entry insideips(each IP carrying its own classification); breaking in0.53.0: the classification moved from the IP to theMonitoritself, soipsreverted to a plain list of strings and these five fields moved up to sit directly on the row.data.name/data.email(since0.57.0): when the row has auser_id, these are the resolved contact for that user, not this Monitor's owndata.name/data.email— see "Stable across a user's Monitors" under "User listing" above. Rows without auser_idkeep whatever was already indatauntouched.- Cached the same way as
getVisitorsByIp/getBlockedIps(see below).
getMonitorVisits(since0.46.0): given amonitor_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 existingMonitorid, respectively), a paginated listing of that device/browser's full visit history (monitor_visits), newest first (iddescending). UnlikegetIpMonitors/getUserMonitors(which only exposevisits_count, a number, per row), this action lets you page through the complete journey history of one specificMonitoron demand (e.g. an "see all visits" expansion in the dashboard). Params:page(default1),per_page(default20, max50). 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 in0.46.0);nullfor a visit recorded before this column existed (not backfilled).paths: the full journey for that visit, in access order (same shape as each entry ofmonitor_visits— see "Visits" below).- Cached the same way as
getVisitorsByIp/getBlockedIps(see below).
getBlockedIps/getBlockedPaths: plain paginated listing ofmonitor_blocked_ips({"ip", "source", "created_at"}) /monitor_pathsrows withstatus: 'trap'({"path", "created_at"}, same shape as before0.20.0) — nofilterparam, justpage/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 exactCOUNT(*)for that table.size_bytes: data + index size for that table (DATA_LENGTH + INDEX_LENGTHfrominformation_schema.tables), MySQL only —nullon 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_bytesis likewisenullunless every table's ownsize_byteswas 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, sogetTableStatsnever 500s just because an installation is a migration behind.
Cached the same way as getVisitorsByIp/getBlockedIps (Cache::remember
- the same shared
monitor:listings:versioncounter, TTLconfig('monitor.listings_cache_ttl_minutes'), default 5 minutes) —clearDataandpruneData(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 asgetData'spackage_version(the version actually installed, via Composer'sInstalledVersions).meta.config_version: same value asgetData'sconfig_version(config('monitor.version'), frozen at whatevermonitor:install/monitor:updatelast wrote into the client's publishedconfig/monitor.php).meta.version_diverged:truewhen both of the above are known and don't match — Composer was updated butmonitor:updatewasn't run afterward (config, and possibly migrations, may be pending). Since0.56.0a leadingv/Vis ignored on both sides before comparing:package_versioncomes from the Git tag (v0.54.0) while the config stores0.54.0, which used to be reported as diverged on every tag-based install.meta.config_published: whetherconfig/monitor.phpis published at all in this project (vendor:publish --tag=monitor-config/monitor:installran at some point). Whenfalse, every key below ismissing_from_file: true— the client is 100% on package defaults.meta.config_cached:app()->configurationIsCached()— whether the host app is currently running withphp 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, viaconfig('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 ownsrc/config/monitor.php(never fromconfig(), which would already be merged).customized:truewhenvaluediffers fromdefault.missing_from_file:truewhen the key is absent from the client's publishedconfig/monitor.phpfile (not just fromconfig(), which already merges package defaults in for missing keys) — meaning the client is silently on the package default for that key, and amonitor:updateis likely pending to persist it.env: the name of the.envvariable the key reads from, ornullwhen 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/defaultare a string like"2 IPs"— the actual IP/CIDR list is never returned.denylist_path:value/defaultare just the filename (basename()), never the absolute path on the client's server.scraper_known_bot_user_agents:value/defaultare 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 thedaysdates 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 is0, not a gap: every array inserieshas exactly as many entries asdays, aligned 1:1 by index.series.monitors_new(categories expanded in0.59.0): everyMonitorwhosecreated_atfalls on that day, split into the same categories asgetData.monitors_by_kind—human_guest/human_user(kind=human, byuser_idpresence),bot(kind=bot),flagged(nokind, seen at a flagged/blocked IP),unclassified(nokind, the rest).humanis kept ashuman_guest + human_userfor compatibility. UnlikegetData, there's no separatenewbucket here — the AI-triage grace period only makes sense for an "as of now" total, not a daily series, so those Monitors fall underunclassified. 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 in0.59.0:clean/scraperremoved): everymonitor_visitsrow whosecreated_atfalls on that day, split by the category of the Monitor that owns the visit (monitor_visits.monitor_id) — same 5 categories asseries.monitors_newabove (minus the legacyhumankey, whichseries.visitsnever had). This is a category of the visitor, not of the individual visit: a visit from a Monitor later reclassified asbotmoves to thebotbucket on its nextgetTimelineread, same asseries.monitors_new.series.access: everymonitor_access_logsrow whoseaccessed_atfalls on that day, split bykind(see "Access log" below) —read_token_issuedis 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 aLog::warning, same pattern asgetData/getTableStats. It never turns the whole action into a500. - Indexed on
monitors.created_atandmonitor_visits.created_at(migration2026_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 exacthost/pathkey, same string as returned bygetPages'data[].path. Matched exactly — unlikeflagScraperPath/markPathSafe, there's no suffix matching here, since the caller already has the exact key from agetPagesrow. Missing/ empty is a422.days(optional, integer, default30): same7–365range and validation asgetTimeline'sdays.
{
"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 asgetTimeline— the app's timezone, oldest first, a day with zero hits is0, never a gap.hits:monitor_page_hits.hitssummed across everyMonitorthat hit thispath, grouped byday, aligned 1:1 withdaysby index.exact_since: same value and meaning asgetPages'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_hitstable that hasn't migrated to thedaycolumn yet comes back as an all-zero series with aLog::warning, same pattern asgetTimeline, never a500.
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:
issueReadTokencall (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, soip/user_agenton 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 byissueReadToken. This one is evidence from the browser side (ip/user_agent/originnever 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 itsread_token_issuedrow. - 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 oldclearData).422if present and not a non-negative integer.categories[](array; absent = all categories). Each value must be one of the 5 categoriesgetData.monitors_by_kindalready uses —human_user,human_guest,bot,flagged,unclassified— see "Aggregated dashboard totals" above andSupport\MonitorCategories(the single place this rule lives;MonitorControllerandSupport\DataPrunerboth read it from there, so the category used to decide what to delete can never drift from the category used to countmonitors_by_kind).422on an empty array, a non-array, or any value outside those 5.unclassifiedhere also covers thenewbucket thatmonitors_by_kindreports separately (a Monitor with nokind, not flagged, regardless of how recently it was created) — a deliberate product decision, not an oversight: age only matters fornewin 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.0clearData— everyMonitorrow is deleted (monitor_page_hits/monitor_visit_ips/monitor_visitsof each go with it via the FKON DELETE CASCADE), andmonitor_ip_statsis never touched. - All 5 categories selected (explicitly, or just by omitting
categories) +older_than_dayspresent: identical to the oldpruneData—Monitor/monitor_ip_statsrows older than the cutoff are deleted, plus themonitor_visits/monitor_page_hitssweeps described in "Visit retention" below. - A category filter that is not "all 5" (regardless of
older_than_days): onlymonitorsrows matching the category (and the age cutoff, if given) are deleted — the FK cascade takes care of that Monitor's ownmonitor_page_hits/monitor_visit_ips/monitor_visits.monitor_ip_statsis per-IP, not per-Monitor — there's no way to restrict it by category without reintroducing the IP↔Monitor mismatch problemsmonitor_labelswas built to avoid (see "IP classification" above) — so it's left completely untouched whenever the category filter isn't "all 5", even ifolder_than_daysis also given.monitor_access_logsis never touched by any of this, same pre-existing rule as always (see "Access log" above).
Note (since
0.71.0): deletingmonitor_ip_statsrows this way (the "all 5 categories +older_than_days" branch above) is the only thing that removes amonitor_ip_stats.visit_count(lifetime_visits/visit_countingetVisitorsByIp, see above) — it's a whole-row delete bylast_seenage, an explicit, user-triggered cleanup. It is never reset/decremented as a side effect of deleting or pruningMonitorrows (the branches above that only deletemonitors/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'svisitsfield (the one that does track current Monitors) is what drops when Monitors are pruned/deleted —lifetime_visitsonly moves when the IP's ownmonitor_ip_statsrow 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_deletedused to undercount whenever amonitor_visits/monitor_page_hitsrow's ownMonitorwas also old enough to be pruned in the same call —Support\DataPruner:: pruneMonitors()deletes thatMonitorfirst, and the FK'sON DELETE CASCADEremoves those child rows immediately, before the separatemonitor_visits/monitor_page_hitssweeps 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:pruneoutput could be lower than the real count. Fixed by counting eachMonitor's cascade-bound children right before deleting it (DataPruner::countCascadedChildren()) and adding that into the totals — seebugs/laravel-monitor.mdhistory inclaude-managerfor 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/getConfigalready use (monitor:pages:version/monitor:listings:version— the same counters any write action, likeupdateBlockedIpsorsetMonitorKind, 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 ofpage/per_page, so there is no fixed key toCache::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_totalfromgetData, plusblocked_attempts_total(same TTL family asgetBlockResultsabove, 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_daysstays required here (422if missing or invalid) — unlikeclearData, where it's optional (absent = no age cutoff).pruneDatanever had an "optional cutoff" concept, and keeping it required avoids silently changing what an existingpruneDatacall does.- The HTTP response keeps the old shape — the same 4 count fields
clearDatareturns 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 —pruneDatahas no concept of a category filter and always sweeps all 5, same asonly_blockedhas been silently ignored here since0.50.0(see below).
⚠️ Breaking change in
0.50.0: this HTTP action no longer acceptsonly_blocked— it always runs the full sweep described above (the oldonly_blocked=falsebehavior), regardless of what the request sends. If a request body still includesonly_blocked(an out-of-date consumer mid-deploy), it's silently ignored, never rejected with422. Restricting the delete to rows belonging to a currently-blocked IP is now a CLI-only capability — seemonitor:prune --only-blockedbelow, whose behavior (and the$onlyBlockedparameter onSupport\DataPruner::prune()it calls) is unchanged. ⚠️ Breaking change in v0.7.0: this parameter was namedonly_scraper_flaggedand matcheddata.flags.scraper/IpStat.flaggedinstead — the automatic, non-cumulative heuristic signal from the last request seen from that IP, never reviewed by anyone. That madepruneDatacapable of permanently deleting rows for an IP on an unreviewed false positive. It was changed to matchmonitor_blocked_ips(an IP the user actually confirmed/blocked) instead. ⚠️ Behavior change in v0.38.0: "blocked" (CLI--only-blocked, and the HTTP action'sonly_blockedbefore0.50.0) used to mean any row inmonitor_blocked_ips, including a temporary block whoseblocked_untilhad 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 checkMonitorMethoditself 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 asclearData'solder_than_days(but, unlike the HTTP action, always required here, with or without--category).--only-blocked(optional flag, default off, CLI-only since0.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, since0.63.0): restrict the delete to a subset of the same 5 categoriesclearData'scategories[]accepts (human_user/human_guest/bot/flagged/unclassified) — same rule, samemonitor_ip_stats/monitor_visits/monitor_page_hitscarve-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 atomicCache::addlock before pruning and releases it in afinallyblock. 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(default1000) caps how manyMonitorrows 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_statsisn't capped — it's bounded by the size ofmonitor_blocked_ipsitself, 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 from90to0(disabled — retention is now opt-in/manual). Until0.45.0,visits_retention_daysdefaulted to90and the automatic trigger (DataPruner::maybeCleanup(), run on every tracked request) calledprune(0, true), which already included this retention sweep — meaning every installation that never publishedconfig/monitor.php(most of them) was already auto-deletingmonitor_visitsolder 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.phploses the old automatic 90-day visit retention on upgrade to0.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.phpkeeps whatever value it has in that file —monitor:updatenever 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 setvisits_retention_daysto a value> 0— the automatic trigger keeps honoring it exactly like before0.46.0— or (b) schedulemonitor:prune --older-than-days=X(without--only-blocked) yourself, which as of0.46.0also sweepsmonitor_visits— see next paragraph.New in
0.46.0:DataPruner::prune($olderThanDays, $onlyBlocked)now additionally deletesmonitor_visitsrows older than the same$olderThanDayscutoff used forMonitor/monitor_ip_stats, whenever$onlyBlockedisfalse— independent of, and on top of,visits_retention_days. This is whatpruneData(only_blocked=false, the dashboard's "partial cleanup" UI) andmonitor: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, independentDELETE(no double-counting: a row either query already removed just isn't found by the other), and its count is folded into the samevisits_deleted. This new sweep never runs from the automatic trigger (maybeCleanup()always callsprune(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_daysnow also deletesmonitor_page_hitsrows whosedayis older than the cutoff — same gate (0= disabled, the default), same call insideDataPruner::prune(). Before this, a visitor's page-hit profile had no retention of its own:visits_retention_dayscleaned up the visit journey inmonitor_visits, but the per-path hit counts behind it inmonitor_page_hitslived forever, even for the anonymous tracker (bots/API, no session — it never creates amonitor_visitsrow 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 andDataPruner::prune()'s return value gain a separatepage_hits_deletedcount (not folded intovisits_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(defaulttrue): journey + IP + a 5-year cookie is personal data — turn it off if your privacy policy doesn't cover it.visit_max_paths(default200): once a visit reaches this many paths it only bumpsupdated_at.visits_retention_days(default0since0.46.0, was90): see "Visit retention" above, including the0.46.0behavior 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 forgedX-Forwarded-Forwould 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-monitormiddleware — 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.