jeylabs/404-email-alert

Email alerts to system admin on 404 URL visits

Maintainers

Package info

github.com/jeylabs/404-email-alert

pkg:composer/jeylabs/404-email-alert

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-master 2026-06-22 14:51 UTC

This package is auto-updated.

Last update: 2026-06-22 14:52:02 UTC


README

Laravel Page Not Found (404) Email Alerts — plus reporting on every not-so-great request.

This package emails your system administrator whenever a visitor hits a URL that does not exist (an HTTP 404 response). Each alert includes the requested URL, HTTP method, referer, IP address, user agent and timestamp, so you can quickly spot broken links and suspicious scanning activity.

On top of the instant 404 alert, it also records every failed request (any 4xx or 5xx response) to the database and can email you a periodic digest — counts by status code, the 4xx/5xx split, and the top offending paths, IP addresses and user agents — so you get the bigger picture, not just one email per missing page.

Requirements

  • PHP >= 8.4
  • Laravel ^13.6

Installation

Install via Composer:

composer require jeylabs/404-email-alert

The service provider is registered automatically through Laravel package auto-discovery — no manual registration required.

Configuration

Publish the configuration file:

php artisan vendor:publish --provider="Jeylabs\PageNotFoundEmailAlert\PageNotFoundEmailAlertServiceProvider" --tag=config

This creates config/page-not-found-email-alert.php. At a minimum, set the recipient address(es). You can do this in .env:

PAGE_NOT_FOUND_ALERT_ENABLED=true
PAGE_NOT_FOUND_ALERT_TO=admin@example.com,ops@example.com
PAGE_NOT_FOUND_ALERT_SUBJECT="404 Page Not Found Alert"
PAGE_NOT_FOUND_ALERT_THROTTLE=60

Options

Key Description
enabled Master on/off switch for alerts.
to One or more recipient addresses (comma separated via env, or an array in the config file).
from The from address/name. Defaults to your app's MAIL_FROM_ADDRESS / MAIL_FROM_NAME.
subject Subject line of the alert email.
throttle Minutes to suppress duplicate alerts for the same URL (prevents flooding). 0 disables it.
ignore Request paths that should never trigger an alert. Supports * wildcards (e.g. favicon.ico).

Alerts require a configured mail driver. If no recipients are set, or enabled is false, no email is sent.

Notification channels (Slack, Teams, Discord, webhook)

Every notification — the instant 404 alert, the digest report and the spike alerts — is a Laravel notification that can be delivered to email and/or chat. Email is on by default; the chat channels post to an incoming-webhook URL and are enabled per provider:

# Slack / Discord / Teams each take an incoming webhook URL
PAGE_NOT_FOUND_SLACK_ENABLED=true
PAGE_NOT_FOUND_SLACK_WEBHOOK=https://hooks.slack.com/services/XXX/YYY/ZZZ

PAGE_NOT_FOUND_DISCORD_ENABLED=true
PAGE_NOT_FOUND_DISCORD_WEBHOOK=https://discord.com/api/webhooks/XXX/YYY

PAGE_NOT_FOUND_TEAMS_ENABLED=true
PAGE_NOT_FOUND_TEAMS_WEBHOOK=https://outlook.office.com/webhook/XXX

# Generic JSON POST to any endpoint of your choosing
PAGE_NOT_FOUND_WEBHOOK_ENABLED=true
PAGE_NOT_FOUND_WEBHOOK_URL=https://ops.example.com/hooks/page-not-found

# Turn email off if you only want chat
PAGE_NOT_FOUND_MAIL_ENABLED=false
Key Description
channels.mail.enabled Deliver notifications by email (default true).
channels.slack enabled + webhook_url for a Slack incoming webhook.
channels.discord enabled + webhook_url for a Discord webhook.
channels.teams enabled + webhook_url for a Microsoft Teams connector.
channels.webhook enabled + url for a generic JSON POST (your own integration).

A channel is used only when it is enabled and configured (a webhook URL for chat, recipients for mail), so the digest/alerts are sent to whichever channels you've set up. Slack, Discord and Teams each receive a message formatted for their incoming-webhook API; the generic webhook receives a provider-agnostic JSON body (event, title, summary, level, url, fields). Chat-delivery failures are logged and never interfere with the request.

Reporting on not-so-great requests

It works out of the box: install the package, run php artisan migrate, and every failed (4xx/5xx) request is recorded. A daily digest is scheduled automatically and starts sending the moment recipients are configured — no changes to your console kernel required. Every part is toggleable from .env, and vendor:publish --tag=config gives you the full file to tune.

At a glance — features and their default state:

Feature Default Toggle (env)
Instant 404 alert on* PAGE_NOT_FOUND_ALERT_ENABLED
Email delivery on PAGE_NOT_FOUND_MAIL_ENABLED
Slack/Teams/Discord off PAGE_NOT_FOUND_SLACK_ENABLED
Recording 4xx/5xx on PAGE_NOT_FOUND_RECORD_ENABLED
Digest report on* PAGE_NOT_FOUND_REPORT_ENABLED
Auto-scheduled digest on* PAGE_NOT_FOUND_REPORT_SCHEDULE
Threshold/spike alerts off PAGE_NOT_FOUND_ALERTS_ENABLED
HTML dashboard off PAGE_NOT_FOUND_DASHBOARD_ENABLED
JSON API off PAGE_NOT_FOUND_API_ENABLED
Google-login gate on PAGE_NOT_FOUND_AUTH_ENABLED
reCAPTCHA on login off PAGE_NOT_FOUND_RECAPTCHA_ENABLED

* Emails only go out once recipients are set, so the email features are safe to leave enabled before you've configured them.

Every 4xx/5xx response is recorded to the database so it can be summarised into a digest. The package ships a migration which runs automatically (Laravel discovers it via the service provider); you can publish it to customise the schema:

php artisan vendor:publish --provider="Jeylabs\PageNotFoundEmailAlert\PageNotFoundEmailAlertServiceProvider" --tag=migrations

Recording options

Key Description
record.enabled Master on/off switch for recording failed requests.
record.table The table failed requests are stored in (default page_not_found_request_logs).
record.statuses Exact status codes to record. Empty = everything at/above minimum_status (all 4xx/5xx).
record.minimum_status When statuses is empty, the lowest status code to record (default 400).
record.retention_days How many days of history to keep, used by --prune (default 30, 0 disables).
record.queue.enabled Record asynchronously via a queued job instead of writing inline (default true).
record.queue.connection Queue connection to dispatch on (null = the app's default).
record.queue.queue Queue name to dispatch on (null = the default queue).

The ignore patterns used for alerts also apply to recording.

Asynchronous recording

By default the database write is pushed onto a queue (RecordBadRequest job) so it never adds latency to error responses:

PAGE_NOT_FOUND_RECORD_QUEUE=true
PAGE_NOT_FOUND_RECORD_QUEUE_CONNECTION=redis   # optional, defaults to your default connection
PAGE_NOT_FOUND_RECORD_QUEUE_NAME=monitoring    # optional, defaults to the default queue

With a real queue connection (database, redis, sqs) the write runs on a worker; with the sync connection it runs inline exactly as before. If your default queue connection is not sync, make sure a worker is running — otherwise records will sit unprocessed. To always write synchronously inside the request, set PAGE_NOT_FOUND_RECORD_QUEUE=false.

Threshold / spike alerts

Beyond the per-URL 404 email and the periodic digest, the package can send a near-real-time alert when error volume crosses a threshold — e.g. "more than 25 server errors in 5 minutes" — so you hear about an outage or attack as it happens. Rules are evaluated as requests are recorded (rate-limited so the check runs at most once per check_interval seconds) and, when scheduled, on a fixed cadence too. A per-rule cooldown prevents repeat emails.

Disabled by default (the thresholds are traffic-specific). Enable and tune:

PAGE_NOT_FOUND_ALERTS_ENABLED=true
PAGE_NOT_FOUND_ALERTS_TO=ops@example.com   # falls back to report.to, then the alert "to"
PAGE_NOT_FOUND_ALERTS_COOLDOWN=30          # minutes between repeat alerts per rule

Rules live in the published config under alerts.rules; each has a name, a status range (min_status/max_status) or explicit statuses list, a threshold count and a window in minutes:

'rules' => [
    ['name' => 'Server error spike', 'min_status' => 500, 'threshold' => 25, 'window' => 5],
    ['name' => 'Client error surge', 'min_status' => 400, 'max_status' => 499, 'threshold' => 200, 'window' => 5],
],

The page-not-found:monitor command evaluates the rules on demand; add --dry to print each rule's current count and breach status without sending anything. When alerts.schedule.enabled is on (default), the monitor is auto-registered on the scheduler every minute, so alerts fire reliably even with bursty traffic (requires schedule:run to be running).

The report command

php artisan page-not-found:report

Compiles the recorded requests for a window (the last 24 hours by default) and emails the digest. Options:

Option Description
--hours= Number of hours to include (defaults to report.period_hours).
--since= Only include records on/after this date-time (overrides --hours).
--to= Override the recipient address(es). Repeatable.
--prune Delete records older than record.retention_days after reporting.
--dry Render the report to the console instead of emailing it.

Recipients are resolved from --to, then report.to (PAGE_NOT_FOUND_REPORT_TO), then the alert to addresses.

Reporting options

Key Description
report.to Digest recipients. Falls back to the alert to addresses when empty.
report.subject Subject line of the digest email.
report.period_hours Default window (hours) included in each report.
report.limit Number of rows shown in each "top" breakdown.
report.send_when_empty Whether to send a report even when nothing was recorded (default false).

Automatic scheduling

By default the package registers the report command on Laravel's scheduler for you, so you only need the scheduler itself to be running (php artisan schedule:run via cron, or schedule:work locally). Tune it from .env:

PAGE_NOT_FOUND_REPORT_SCHEDULE=true       # set false to schedule it yourself
PAGE_NOT_FOUND_REPORT_FREQUENCY=daily     # hourly|daily|twiceDaily|weekly|monthly|<cron>
PAGE_NOT_FOUND_REPORT_TIME=08:00          # for daily/weekly/monthly
PAGE_NOT_FOUND_REPORT_SCHEDULE_PRUNE=true # also prune old records each run

frequency also accepts a raw cron expression, e.g. PAGE_NOT_FOUND_REPORT_FREQUENCY="0 */6 * * *" for every six hours.

Prefer to wire it up manually? Set PAGE_NOT_FOUND_REPORT_SCHEDULE=false and add it to routes/console.php yourself:

use Illuminate\Support\Facades\Schedule;

Schedule::command('page-not-found:report --prune')->dailyAt('07:00');

Customise the digest email by publishing the views (see below); the digest template is report.blade.php.

Dashboard & JSON API

The same data can be browsed in-app via an HTML dashboard, or consumed as JSON. Both are disabled by default because they expose request data (URLs, IPs, user agents) — enable them and put them behind appropriate middleware before using them in production.

PAGE_NOT_FOUND_DASHBOARD_ENABLED=true
PAGE_NOT_FOUND_API_ENABLED=true
Key Description
dashboard.enabled Serve the HTML dashboard.
dashboard.path URI prefix for the dashboard (default page-not-found).
dashboard.middleware Middleware applied to the dashboard route (default ['web'] — add auth etc.).
api.enabled Serve the read-only JSON endpoint.
api.path URI prefix for the API (default api/page-not-found).
api.middleware Middleware applied to the API route (default ['api']).

Both accept a ?hours= query parameter to change the window (e.g. /page-not-found?hours=168 for the last 7 days). The dashboard view can be customised by publishing the views; its template is dashboard.blade.php.

The dashboard opens with a "Requests over time" chart — a zero-filled, stacked (4xx/5xx) time-series bucketed by minute/hour/day depending on the window — and the digest email includes a "Busiest periods" summary. The same data is exposed under series in the API payload (series.unit and an array of series.points, each with period, total, client_errors, server_errors), so you can build your own charts.

Bot vs human & referer insight

Every recorded request is classified at write time:

  • Bot vs human — the user agent is matched against a built-in list of crawlers, HTTP clients, headless browsers and security scanners (extend it via record.bot_user_agents). This separates real broken links from scanner noise.
  • Referer — classified as internal (the referer host matches your app's host), external, or direct (no referer). Internal referers are your own pages linking to dead URLs — the most actionable 404s.

The dashboard surfaces both as summary cards plus a "Top referers" table, the digest email includes the splits, and the API exposes them under traffic (humans/bots/unknown), referers (internal/external/direct) and top_referers.

Drill-down & filtering

Click any path on the dashboard to open /page-not-found/requests — a paginated list of the individual hits behind the aggregates. It supports filtering by exact path, free-text path search, status code, window, and human/bot, all via query parameters (e.g. /page-not-found/requests?search=wp-admin&bot=1). The route lives in the dashboard group, so it inherits the same access control.

Access control: Sign in with Google

The dashboard and API are protected by Sign in with Google out of the box (auth.enabled defaults to true). Only the email addresses you allow-list can view the dashboard or call the API — anyone else is redirected to a login screen or, for the API, receives a 401. The same browser session grants access to both, so once you've signed in for the dashboard the API works too.

  1. In the Google Cloud console create an OAuth 2.0 Client ID (type: Web application).
  2. Add the callback as an Authorised redirect URI — by default https://your-app.test/page-not-found/auth/callback.
  3. Configure .env:
PAGE_NOT_FOUND_AUTH_ENABLED=true
PAGE_NOT_FOUND_AUTH_EMAILS=you@yourcompany.com,ops@yourcompany.com
PAGE_NOT_FOUND_GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
PAGE_NOT_FOUND_GOOGLE_CLIENT_SECRET=your-secret
Key Description
auth.enabled Protect the dashboard/API with Google login (default true).
auth.allowed_emails The Google emails permitted to sign in. Empty = nobody (locked down).
auth.path URI prefix for the login/callback routes (default page-not-found/auth).
auth.google.* OAuth client id/secret, and an optional redirect URI override.

No allow-listed email matches? Access is denied — so the feature fails closed. You can still set middleware on the routes for an extra layer (VPN/IP).

Bot protection: reCAPTCHA

The login entry point can be guarded with Google reCAPTCHA (v2 checkbox or v3). Add your keys and enable it:

PAGE_NOT_FOUND_RECAPTCHA_ENABLED=true
PAGE_NOT_FOUND_RECAPTCHA_SITE_KEY=your-site-key
PAGE_NOT_FOUND_RECAPTCHA_SECRET_KEY=your-secret-key
# v3 only — minimum passing score (0.0–1.0):
PAGE_NOT_FOUND_RECAPTCHA_MIN_SCORE=0.5

When enabled, the login form renders the reCAPTCHA widget and the redirect to Google is rejected unless the captcha verifies (for v3, the score must meet min_score). This keeps bots from hammering the OAuth flow.

Customising the email template

To change the email layout, publish the view and edit it:

php artisan vendor:publish --provider="Jeylabs\PageNotFoundEmailAlert\PageNotFoundEmailAlertServiceProvider" --tag=views

The view is published to resources/views/vendor/page-not-found-email-alert/email.blade.php.

How it works

The package appends a lightweight global middleware to the HTTP kernel. After each request is handled, the middleware inspects the response:

  • When it is a 404, not in the ignore list, and the URL has not already triggered an alert within the throttle window, the instant alert email is dispatched.
  • When it is any 4xx/5xx (and not ignored), it is recorded for reporting — by default via a queued job so the response is never slowed by a database write. The user agent (bot vs human) and referer (internal/external/direct) are classified as the row is written. Recording is skipped silently until the migration has run, so it never spams your logs.

Mail and storage failures are caught, logged, and never interfere with the response returned to the user. The digest command and the dashboard/API read back the recorded rows and aggregate them on demand.

License

MIT