mattitja / bad-ip-blocker
Block bad IPs in Laravel using a local JSON list with automatic blocking of scanners.
Requires
- php: ^8.2
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.24
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-12 20:45:16 UTC
README
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