On a VPS you can install fail2ban, tail the system logs and plug in whatever monitoring you like. On shared hosting you get an FTP account, a cron panel and a database. If someone hammers your login form at 3am, nothing tells you.
The mrdebug/laravel-watchdog package plugs into what Laravel already provides: the Login, Failed and MessageSent events, the exception handler, and the notification system. Alerts are regular Laravel notifications sent through a watchdog-discord channel, so you can replace any of them with your own class the same way you would customize a mail notification. No agent to install, no third-party service to sign up for, just a Discord webhook URL. This post walks through a full setup, from composer require to the first alert.
What we are going to build
By the end you will have:
- 404 and 422 responses stored in the database, with the validation errors for 422.
- Failed and successful logins recorded.
- A Discord message when a 500 error happens, when an admin logs in, or when someone tries to brute-force a login.
- A daily 404/422 summary.
- Old records pruned automatically.
Watchdog detects, it does not block. It will tell you an IP has failed 8 logins in an hour, but banning it is up to you.
Install the package
composer require mrdebug/laravel-watchdog
php artisan vendor:publish --tag=laravel-watchdog-config
php artisan migrate
All tables are prefixed with watchdog_, so they will not collide with yours.
Create the Discord webhooks
In Discord, open the channel settings, go to Integrations, then Webhooks, and create one. I use three channels so the noise stays separated:
DISCORD_WEBHOOK_LOGINS=https://discord.com/api/webhooks/...
DISCORD_WEBHOOK_ERRORS=https://discord.com/api/webhooks/...
DISCORD_WEBHOOK_SECURITY_LOGS=https://discord.com/api/webhooks/...
These URLs are secrets. Keep them in .env and rotate them if they leak.
By default, alerts are only sent when the app runs in production. While testing locally, set this in config/laravel-watchdog.php:
'discord' => [
'only_production' => false,
// ...
],
Hook into the exception handler
Laravel 11+ configure exceptions in bootstrap/app.php, which runs before service providers boot, so the package cannot register itself there. One line does it:
use MrDebug\LaravelWatchdog\Support\ExceptionHandler as WatchdogExceptionHandler;
->withExceptions(function (Exceptions $exceptions): void {
WatchdogExceptionHandler::register($exceptions);
// Your own render/report callbacks go after this line.
})
Order matters. Laravel stops at the first render() callback that returns a response, so if one of yours comes first, Watchdog never sees the 404 or the 422.
Choose what to track
Traffic logging is opt-in per route group through two middlewares:
Route::middleware('log-stats')->group(function () {
// Public pages, guests included
});
Route::middleware(['auth', 'log-activity'])->group(function () {
// Authenticated area, tied to a user_id
});
Admins are skipped. By default, a user is an admin if role is admin. If your app works differently, implement the contract:
namespace App\Support;
use Illuminate\Contracts\Auth\Authenticatable;
use MrDebug\LaravelWatchdog\Contracts\AdminChecker;
final class AppAdminChecker implements AdminChecker
{
public function isAdmin(?Authenticatable $user): bool
{
return $user?->is_admin === true;
}
}
// config/laravel-watchdog.php
'admin_check' => [
'checker' => \App\Support\AppAdminChecker::class,
],
Logins, failed logins and sent emails need no wiring. The service provider listens to Laravel's Login, Failed and MessageSent events on its own.
Schedule the commands
Declare the three commands in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('watchdog:analyze-logins')->hourly();
Schedule::command('watchdog:daily-security-report')->dailyAt('08:00');
Schedule::command('watchdog:prune')->daily();
analyze-logins looks at the failed attempts of the last 60 minutes. If a single email or a single IP reaches 5 failures, you get an alert. Both the window and the thresholds are configurable:
'suspicious_activity' => [
'window_minutes' => 60,
'threshold_per_email' => 5,
'threshold_per_ip' => 5,
],
A word about queues
Admin login and 500 alerts are queued notifications. Most shared plans cannot keep a queue worker alive, so the simplest option is:
QUEUE_CONNECTION=sync
Alerts are then sent immediately during the request. If your host allows it, a cron running php artisan queue:work --stop-when-empty every minute also works.
Test it
Add a temporary route:
Route::get('/boom', fn () => throw new RuntimeException('Test alert'));
Visit /boom and a message like this should land in your errors channel:
🔴 500 Error
`Test alert`
/app/routes/web.php:12
https://example.com/boom
Now visit a URL that does not exist, then check the watchdog_security_logs table. You should see the 404 with the request input.
For the brute-force alert, fail a login six times with the same email, then run php artisan watchdog:analyze-logins by hand:
🚨 Suspicious activity detected
By email :
• john@example.com : 6 failures
Remove the /boom route when you are done.
Things to know before going live
It stores sensitive data by default. The 404 and 422 payloads are saved in full so you can reproduce problems. That includes passwords submitted on a form that fails validation. Redact them:
'security_excluded_params' => ['password', 'password_confirmation', 'current_password', '_token'],
Sent emails are stored too, with their bodies and headers, so password-reset links end up in the table. Keep the retention short and restrict database access.
Retention is yours to set. On a small shared database, size matters:
'prune' => [
'stats_logs' => 30,
'activity_logs' => 90,
'security_logs' => 90,
'login_attempts' => 90,
'sent_emails' => 30,
],
Writes are synchronous. Each tracked request adds a row. Put log-stats on the route groups you care about rather than on everything.
Rate limiting on 500s. A broken page hit by a crawler can generate hundreds of errors. Watchdog sends at most 5 Discord messages per exception class per hour by default, using Laravel's cache.
Wrapping up
The whole setup takes about fifteen minutes, and it gives a shared-hosting app the basic visibility a VPS gets from its tooling. It will not replace Sentry or a proper log stack, and it does not ban anyone, but it answers the question I kept asking myself: is something going wrong right now?
What to do after a week
Once it has been running for a few days, open watchdog_security_logs and look at what your app actually receives. You will probably see bots probing /wp-login.php and /.env, which is a good reason to raise the thresholds or add a few fields to security_excluded_params. If one email or IP keeps showing up in the login alerts, you now have what you need to block it at the host level or in your own middleware.
The source and the full list of config options are on GitHub and Packagist.
Top comments (0)