PHPackages                             jeylabs/404-email-alert - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Mail &amp; Notifications](/categories/mail)
4. /
5. jeylabs/404-email-alert

ActiveLibrary[Mail &amp; Notifications](/categories/mail)

jeylabs/404-email-alert
=======================

Email alerts to system admin on 404 URL visits

1.1.0(1mo ago)041MITPHPPHP ^8.4CI passing

Since Jun 21Pushed 1mo ago8 watchersCompare

[ Source](https://github.com/jeylabs/404-email-alert)[ Packagist](https://packagist.org/packages/jeylabs/404-email-alert)[ Docs](https://github.com/jeylabs/404-email-alert)[ RSS](/packages/jeylabs-404-email-alert/feed)WikiDiscussions master Synced 5d ago

READMEChangelogDependencies (9)Versions (3)Used By (0)

404-email-alert
===============

[](#404-email-alert)

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
------------

[](#requirements)

- PHP `>= 8.4`
- Laravel `^13.6`

Installation
------------

[](#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
-------------

[](#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

[](#options)

KeyDescription`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)
------------------------------------------------------

[](#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
```

KeyDescription`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
----------------------------------

[](#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:

FeatureDefaultToggle (env)Instant 404 alerton\*`PAGE_NOT_FOUND_ALERT_ENABLED`Email deliveryon`PAGE_NOT_FOUND_MAIL_ENABLED`Slack/Teams/Discordoff`PAGE_NOT_FOUND_SLACK_ENABLED` …Recording 4xx/5xxon`PAGE_NOT_FOUND_RECORD_ENABLED`Digest reporton\*`PAGE_NOT_FOUND_REPORT_ENABLED`Auto-scheduled digeston\*`PAGE_NOT_FOUND_REPORT_SCHEDULE`Threshold/spike alertsoff`PAGE_NOT_FOUND_ALERTS_ENABLED`HTML dashboardoff`PAGE_NOT_FOUND_DASHBOARD_ENABLED`JSON APIoff`PAGE_NOT_FOUND_API_ENABLED`Google-login gateon`PAGE_NOT_FOUND_AUTH_ENABLED`reCAPTCHA on loginoff`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

[](#recording-options)

KeyDescription`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

[](#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

[](#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

[](#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:

OptionDescription`--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

[](#reporting-options)

KeyDescription`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

[](#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|
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 &amp; JSON API
------------------------

[](#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
```

KeyDescription`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 &amp; referer insight

[](#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 &amp; filtering

[](#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

[](#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](https://console.cloud.google.com/apis/credentials)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
```

KeyDescription`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

[](#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

[](#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
------------

[](#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
-------

[](#license)

MIT

###  Health Score

41

—

FairBetter than 87% of packages

Maintenance90

Actively maintained with recent releases

Popularity4

Limited adoption so far

Community13

Small or concentrated contributor base

Maturity52

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 59.1% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Unknown

Total

1

Last Release

52d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/3ed0d4b2d9847ab8f33bfaa6f10e26f331104554bd9ede60a59d12ebc7cca873?d=identicon)[jeylabs](/maintainers/jeylabs)

---

Top Contributors

[![claude](https://avatars.githubusercontent.com/u/81847?v=4)](https://github.com/claude "claude (13 commits)")[![jey-srikantha](https://avatars.githubusercontent.com/u/82044031?v=4)](https://github.com/jey-srikantha "jey-srikantha (9 commits)")

---

Tags

laravelemailroutingalert404not found

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/jeylabs-404-email-alert/health.svg)

```
[![Health](https://phpackages.com/badges/jeylabs-404-email-alert/health.svg)](https://phpackages.com/packages/jeylabs-404-email-alert)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3345.4M354](/packages/psalm-plugin-laravel)[laravel/cashier

Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.

2.6k31.8M158](/packages/laravel-cashier)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

79227.1M218](/packages/laravel-mcp)[api-platform/laravel

API Platform support for Laravel

58190.1k21](/packages/api-platform-laravel)[fleetbase/core-api

Core Framework and Resources for Fleetbase API

1239.7k25](/packages/fleetbase-core-api)[forjedio/inertia-table

Backend-driven dynamic tables for Laravel + Inertia.js

272.0k](/packages/forjedio-inertia-table)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
