4.7 KiB
Bad IP Blocker for Laravel
Block requests from bad IP addresses using a local JSON list, and automatically block clients that
probe for paths no normal visitor requests (/.well-known/security.txt, /.env, /wp-login.php, …).
Features
- Local JSON list – no external service, no database
- Automatic blocking of scanners (only when the requested path returns 404 in your app)
- Artisan commands and a facade to manage the list
- Optional expiry (TTL) per block
- Whitelist with CIDR support (private and loopback ranges by default)
- Laravel 11, 12 and 13
Installation
composer require mattitja/bad-ip-blocker
Publish the config (optional):
php artisan vendor:publish --tag=bad-ip-blocker-config
Usage
That's it – after composer require the middleware is registered globally and starts blocking.
Expired blocks are pruned daily via the Laravel scheduler (make sure schedule:run is running).
If you'd rather register the middleware yourself, set BAD_IP_BLOCKER_AUTO_REGISTER=false and add it in
bootstrap/app.php:
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
->withMiddleware(function (Middleware $middleware) {
$middleware->append(CheckBadIps::class);
})
It must run globally (not only in a route group) so it also sees requests for routes that don't exist. Registering it both ways is harmless – it only runs once.
Behind a proxy or load balancer – important
This is the one thing you may need to configure. The middleware uses $request->ip(). Behind a reverse
proxy, load balancer or Cloudflare this returns the proxy's address unless you trust the proxy. Configure it
so the real client IP from X-Forwarded-For is used:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['10.0.0.0/8']); // your proxy addresses
})
Private ranges are whitelisted by default, so a missing proxy config never blocks every visitor. But until you configure trusted proxies, nothing is blocked behind the proxy either – it fails silently.
Managing the list
Artisan
php artisan bad-ip-blocker:add 203.0.113.10 --reason="Scraper" --ttl=86400
php artisan bad-ip-blocker:remove 203.0.113.10
php artisan bad-ip-blocker:list
php artisan bad-ip-blocker:prune # remove expired blocks
php artisan bad-ip-blocker:clear --force # remove all blocks
Facade
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
BadIpBlocker::block('203.0.113.10', 'Scraper', ttl: 3600); // ttl in seconds, null = permanent
BadIpBlocker::unblock('203.0.113.10');
BadIpBlocker::isBlocked('203.0.113.10');
BadIpBlocker::all();
Pruning
Expired blocks are ignored automatically and removed from the file by a daily scheduled
bad-ip-blocker:prune. Disable it with BAD_IP_BLOCKER_SCHEDULE_PRUNE=false.
Automatic blocking
When a request matches a pattern in auto_block.paths and your app responds with 404, the IP is blocked.
Routes that exist in your app are never affected – if you publish your own security.txt, reading it is fine.
If your app has a
Route::fallback()that returns 200 (e.g. an SPA), matching requests never return 404, so automatic blocking won't trigger for them.
Default patterns include .well-known/security.txt, security.txt, .env*, .git/*, wp-login.php,
wp-admin*, xmlrpc.php, phpmyadmin*, vendor/phpunit/* and *.php. Ordinary 404s
(e.g. /missing-page) never trigger a block.
Tune it in config/bad-ip-blocker.php:
| Key | Default | Description |
|---|---|---|
auto_block.enabled |
true |
Env BAD_IP_BLOCKER_AUTO_BLOCK |
auto_block.paths |
see above | Request::is() patterns |
auto_block.max_hits |
1 |
Matching 404s before blocking |
auto_block.decay |
3600 |
Seconds a hit counter is kept |
auto_block.ttl |
604800 (7 days) |
Env BAD_IP_BLOCKER_AUTO_BLOCK_TTL, null = permanent |
Configuration
| Key | Default |
|---|---|
enabled |
true (env BAD_IP_BLOCKER_ENABLED) |
auto_register |
true (env BAD_IP_BLOCKER_AUTO_REGISTER) |
schedule_prune |
true (env BAD_IP_BLOCKER_SCHEDULE_PRUNE) |
storage_path |
storage/app/bad-ip-blocker.json (env BAD_IP_BLOCKER_STORAGE_PATH) |
response.status / response.message |
418 / Blocked. |
whitelist |
loopback + private ranges (IPs or CIDR) |
The list is a plain JSON file per server. If you run several servers or containers, point storage_path at
persistent, shared storage – otherwise each instance has its own list and a container's list is lost on redeploy.
Example response when blocked
HTTP/1.1 418 I'm a teapot
Content-Type: text/html; charset=UTF-8
Blocked.
Testing
composer test
License
MIT License © Mattitja AB