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)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 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-09-22 12:46:15 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.
Updating
First-time setup is composer require drcantagalo/laravel-monitor followed
by php artisan monitor:install (interactive: terms, publish
config/migrations, migrate, 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.
Dashboard is optional (since 0.31.0): right before asking for the
site URL, monitor:install asks whether you want to use the hosted
dashboard/interface (monitor.cantagalo.it). Answering no skips the URL
question and the remote registration entirely — no local_token is
generated, 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, 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).
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,
"sessions_total": 21044,
"unique_ips_total": 8117,
"blocked_attempts_total": 342
}
visitors_total:Monitor::count()— one row per recognized device/browser (see "Remember-me" above).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: since0.42.0a visit is a PHP session, so this is the same number asvisits_total. Both keys are kept in the response for backward compatibility.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.- All four new totals 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).
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/getUserVisits 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.
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, getUserVisits)
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.)
getUserVisits: givenuser_id(required,422if missing), paginated listing of that user'sMonitorrows (viaMonitor::forUserId($id), newest first) —id,data,created_at,updated_at, plus (since0.42.0)visits: that device's last 20 rows ofmonitor_visits(id,pathsin access order,scraper,created_at,updated_at). Since0.42.0data.pageanddata.ipsare no longer stored in the blob, so this action rebuilds them into each row'sdatafrommonitor_page_hits({path: hits}) andmonitor_visit_ips(list of IPs), keeping the response shape existing dashboards read (row.data.page,row.data.ips). Samepage/per_pageparams asgetUsers.name/email: the package never queries a host app'suserstable (arbitrary schema, out of scope for a host-agnostic package). Instead,getUsersopportunistically readsdata['name']/data['email']off that user's most recently updatedMonitorrow — 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.
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.
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,getBlockedIps,getBlockedPaths,getUsers,getUserVisits,getBlockResults).clearData,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.high_cumulative_visits: the IP's total visit count (monitor_ip_stats.visit_count, including the current request) reachesconfig('monitor.scraper_cumulative_visits_threshold')(default5000). Independent of and complementary tohigh_frequencyabove — that one catches a burst in a short window, this one catches a "patient" scraper that spreads a high volume over a long time and never bursts (e.g. one request per minute for weeks), which never tripshigh_frequencyno matter how long it keeps going.
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).
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 formatCarbon/the DB driver accepts for awherecomparison): filters by theMonitorrow'supdated_at, not a per-page-hit timestamp — the schema has no per-visit timestamp (one row aggregates every page a visitor hit), so this is "that visitor was active in this window", not "this path was hit on this exact date". Good enough to narrow down recent activity; don't rely on it for exact per-hit auditing.- 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}}.
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 (one row per Monitor+path, unique on (monitor_id, path)) is
kept in sync automatically whenever a Monitor is saved — via a model
event, so this works no matter how the row was written (the trackers,
Monitor::create() directly, tinker, tests) — and getPages now
aggregates it with a single GROUP BY query (SUM(hits),
MAX(not_found)), joined against monitors.updated_at only when
date_from/date_to are given. 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 runs a one-time backfill migration
that populates monitor_page_hits from whatever Monitor.data already
exists — expect it to take roughly as long as the old per-request scan
used to (a few seconds per ~1k rows), but it only runs once, at migrate
time, not on every getPages call.
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— 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). Response:{"success": true, "data": [{"ip": "1.2.3.4", "visit_count": 12, "first_seen": "...", "last_seen": "...", "flagged": false, "flagged_signals": null, "blocked": false}, ...], "meta": {"page", "per_page", "total", "last_page"}}.- Regardless of which
filteris requested, results are always ordered withflagged = truerows first (the "possible scraper" work queue), falling back to the existingvisit_count descordering 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.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.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
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.
Partial cleanup (pruneData)
GET /monitor/handler?action=pruneData — same auth as clearData/
updateBlockedIps: requires the permanent local_token, never
accepted with the ephemeral read token from issueReadToken.
Complements clearData (full truncate of Monitor, unchanged) with a
partial, filtered delete:
older_than_days(required, non-negative integer —422if missing or invalid): deletesMonitorrows whoseupdated_atis older thannow() - older_than_daysdays, andmonitor_ip_statsrows whoselast_seenis older than the same cutoff.only_blocked(optional boolean, defaultfalse): whentrue, restricts the delete to rows belonging to an IP currently blocked inmonitor_blocked_ips— matched viamonitor_visit_ipsonMonitor, theipcolumn onIpStat— instead of every row past the cutoff.⚠️ Breaking change in v0.7.0: this parameter was named
only_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 now matchesmonitor_blocked_ips(an IP the user actually confirmed/blocked) instead. ⚠️ Behavior change in v0.38.0: "blocked" here 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.only_blockednow 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()).
Response: {"success": true, "monitors_deleted": 12, "ip_stats_deleted": 4}.
Bumps the getPages/getVisitorsByIp listing cache version counters
(invalidatePagesCache/invalidateListingsCache) whenever something
was actually deleted from the corresponding table, same mechanism as
flagScraperPath/updateBlockedIps etc.
monitor:prune (since 0.27.0)
Artisan equivalent of pruneData above — same options, 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:
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 aspruneData'solder_than_days.--only-blocked(optional flag, default off): same semantics aspruneData'sonly_blocked— restrict the delete to rows belonging to a confirmed/blocked IP (monitor_blocked_ips).
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 (default 90, 0 disables) 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. monitor:prune's
output and DataPruner::prune()'s return value gained a visits_deleted
count.
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 |
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.
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(default90): see "Visit retention" above.
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).
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.