ramir1 / laravel-bot2ban
.
Requires
- php: ^8.3
- laravel/framework: ^13.0
Requires (Dev)
- larastan/larastan: ^2.0 || ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^11.0
- pestphp/pest: ^3.0 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
README
A package for detecting suspicious requests typical of scanners, bots, and vulnerability-hunting tools
(/wp-login.php, /.env, PHP shells, xmlrpc.php, phpmyadmin, etc).
The package does not block IP addresses and does not keep state between requests. Its job is to detect a suspicious request, log information about it to a dedicated log file, and return a preconfigured HTTP status code. The actual IP blocking at the OS/firewall level should be done by an external tool (e.g. Fail2Ban) that scans this log — ready-made config templates for it are included.
Installation
composer require ramir1/laravel-bot2ban
The package registers its own service provider and hooks the middleware in globally (via
Kernel::pushMiddleware) — meaning every request is checked, including requests to routes that don't
exist (/wp-login.php and similar paths that a Laravel app never has a real route for).
Publish the config files (individually or all at once):
php artisan vendor:publish --tag=bot2ban-config # main config php artisan vendor:publish --tag=bot2ban-signatures # signature definitions php artisan vendor:publish --tag=bot2ban-fail2ban # fail2ban templates php artisan vendor:publish --tag=bot2ban # everything at once
How it works
- A request passes through
Bot2BanMiddleware. - If the IP or URI matches the whitelist (
config('laravel-bot2ban.whitelist')) — the request is passed through without any checks. - The request is matched against the signature registry (
SignatureRegistry). Each signature matches IP/URI/User-Agent using one of 4 rule types (exact,startswith,contains,endswith) and has a severity level:- Block — an unambiguous sign of scanning/exploitation (e.g.
wp-admin,.env,phpmyadmin, dangerous extensions). Returned immediately as soon as any Block signature matches. - Warning — a more generic pattern that could theoretically collide with a legitimate app route
(e.g. the prefix
/backend,/dev). Returned only if no Block signature matched.
- Block — an unambiguous sign of scanning/exploitation (e.g.
- On a match — the event is written to a dedicated log channel (
storage/logs/bot2ban-YYYY-MM-DD.logby default, rotated daily), and the request is terminated with the configured HTTP status code (418 for Block, 403 for Warning by default). - Fail2ban on the server scans these files and bans the IP at the OS level — see Fail2ban.
What you'll see when a signature triggers
Step 4 above happens via Laravel's abort($code), exactly like any other abort() call in a Laravel app
(404, 403, etc.) — it throws an HttpException that unwinds up through every middleware above
Bot2BanMiddleware in the stack. That's expected and harmless: those middleware simply have a stack frame
at their return $next($request); line, they aren't the source of anything.
What that renders as depends on APP_DEBUG:
APP_DEBUG=true(typical local/.locsetup) — you'll see Laravel's full debug error page with the entire stack trace for the aborted request. This is expected, not a bug — it's the same page you'd get for any deliberately thrownHttpExceptionwhile debugging.APP_DEBUG=false(production) — a clean response with the configured status code, no trace.
If you disable APP_DEBUG and still see the debug page, and the app runs under Laravel Octane: the
already-running workers keep the config they booted with in memory and won't notice a .env change on
disk. Re-run php artisan config:cache (if your deploy caches config) and then
php artisan octane:reload (or fully restart Octane) — this is a general Octane behavior, not specific to
this package (see Laravel Octane below).
Configuration
Main config — config/laravel-bot2ban.php (after publishing):
enable— turns the package on/off (enabled by default only inproduction).require_domain— protection against bare-IP/wrong-domain access (checked only in production).whitelist.ip/whitelist.uri— exceptions that are passed through without detection (whitelist contains/horizonby default).levels.warning/levels.block—enabled(whether to enforce/abort) andresponse_codefor each level.log_channel/log_path— where the log for fail2ban is written. The channel uses thedailydriver, so the actual files are namedbot2ban-YYYY-MM-DD.log.log_retention_days— how many days to keep old log files before Laravel deletes them automatically (default 14 — with headroom over the fail2ban jails'bantimeof 7 days). Rotation/cleanup needs neither cron nor logrotate on the server.debug_mode— extra diagnostic messages via the app's standard logger (not thebot2banchannel).
Signatures
The signature list is not stored in the main config — it lives in a separate registry
(SignatureRegistry), assembled from config/laravel-bot2ban-signatures.php (after publishing) plus any
signatures the app registers programmatically.
The core signature set — Ramir1\LaravelBot2Ban\Signatures\CoreSignatures — covers dangerous file
extensions, WordPress paths, admin panels (phpMyAdmin/Adminer/cPanel), path traversal, .env/.git/
credential leakage, etc.
Adding your own signatures
Declaratively — after publishing laravel-bot2ban-signatures.php, add your own key:
return [ 'signatures' => \Ramir1\LaravelBot2Ban\Signatures\CoreSignatures::definitions() + [ 'project.internal-tool' => [ 'target' => 'uri', 'type' => 'startswith', 'patterns' => ['/internal-ops'], 'level' => 'block', ], ], ];
Imperatively — via the Bot2Ban facade in boot() of any app service provider:
use Ramir1\LaravelBot2Ban\Facades\Bot2Ban; use Ramir1\LaravelBot2Ban\Signatures\{Signature, SignatureTarget, MatchType, Severity}; Bot2Ban::register(new Signature( id: 'project.internal-tool', target: SignatureTarget::Uri, type: MatchType::StartsWith, patterns: ['/internal-ops'], level: Severity::Block, ));
Rejecting signatures and safe package updates
An app can cancel (reject) a specific core signature if it conflicts with a real app route (e.g. the
site legitimately uses /wp-admin for its own purposes), without touching its definition — a single line
in config/laravel-bot2ban.php is enough:
'signatures' => [ 'default_new_signature_policy' => 'disabled', 'decisions' => [ 'core.wordpress-and-xmlrpc-paths' => 'rejected', // this site legitimately uses /wp-admin 'core.generic-admin-path-words' => 'approved', ], ],
By default, decisions already contains 'approved' for every core signature of the currently
installed package version — protection works out of the box. The "safety net" only kicks in once you
publish this config (vendor:publish --tag=bot2ban-config): the published copy is a snapshot frozen at
publish time, so if a later package version adds a new core signature, its id won't be in your decisions,
and it'll fall under default_new_signature_policy (disabled by default — detected and logged, but not
enforced) instead of suddenly starting to block production right after composer update.
Package update procedure:
composer update ramir1/laravel-bot2ban php artisan bot2ban:signatures:list
The command shows every known signature (core + custom) with its decision status
(approved/rejected/pending) and effective behavior. New signatures marked pending can be explicitly
approved/rejected by adding a line to decisions.
Nginx (optional)
An optional fast-path in front of Laravel: nginx rejects the most obvious scanner probes
(.env/.git, WordPress paths, known admin panel names, etc.) with 444 before PHP-FPM/Octane is even
invoked — cheaper than letting Bot2BanMiddleware handle them. The signature list in this snippet is
deliberately small and static — it should almost never need editing. Anything extensible or
evolving (custom signatures, per-app whitelisting, Warning vs Block, approve/reject governance) belongs in
the Laravel-side signature registry instead, not here — don't try to keep the two in sync
1:1, that reintroduces the exact per-site config maintenance problem this package exists to avoid.
php artisan vendor:publish --tag=bot2ban-nginx
The file appears at <app>/nginx/antibot.conf — include it inside your site's server { } block:
server { # ... include /path/to/app/nginx/antibot.conf; }
Since this layer returns 444 directly from nginx, it never reaches Laravel — these requests won't show up
in bot2ban.log or get caught by the bot2ban-block/bot2ban-warning fail2ban jails below. Publishing
--tag=bot2ban-fail2ban also includes a separate matching filter/jail pair for this nginx layer
(bot2ban-nginx-instant/bot2ban-nginx-scan, watching the nginx access log instead) — symlink them the
same way as the other jails (see Fail2ban):
ln -s /path/to/app/fail2ban/filter.d/bot2ban-nginx-instant.conf /etc/fail2ban/filter.d/ ln -s /path/to/app/fail2ban/filter.d/bot2ban-nginx-scan.conf /etc/fail2ban/filter.d/ ln -s /path/to/app/fail2ban/jail.d/bot2ban-nginx.conf /etc/fail2ban/jail.d/
Fail2ban
Publish the templates:
php artisan vendor:publish --tag=bot2ban-fail2ban
The files will appear under <app>/fail2ban/{filter.d,jail.d} — copy/symlink them into the system paths
and adjust logpath for your deployment (multiple sites on one server — use a * glob, a single site — a
specific path). The logpath template already contains the bot2ban-*.log mask (the log rotates daily,
see log_retention_days above) — fail2ban picks up each new day's file on its own, nothing extra to
configure:
ln -s /path/to/app/fail2ban/filter.d/bot2ban-block.conf /etc/fail2ban/filter.d/ ln -s /path/to/app/fail2ban/filter.d/bot2ban-warning.conf /etc/fail2ban/filter.d/ ln -s /path/to/app/fail2ban/jail.d/bot2ban.conf /etc/fail2ban/jail.d/ systemctl reload fail2ban
Log line format: [{date}] bot2ban.{LEVEL}: {ip} {signature_id} "{METHOD} {URI}" "{USER-AGENT}" (LEVEL is
WARNING or CRITICAL) — deliberately close to Laravel's own [date] channel.LEVEL: message log format, so
tools like opcodesio/log-viewer detect and render it correctly instead of misreading it as an Apache/Nginx
access log. The [date] bot2ban.LEVEL: prefix is entirely package-controlled (never derived from request
data), and the IP is the first token right after it — before signature_id and well before any field an
attacker could control (URI/User-Agent) — this protects the filter regexes from log injection.
bot2ban-block— instant ban (1 attempt / 60 sec).bot2ban-warning— cumulative ban (5 attempts / 5 min).
Both — for 7 days (bantime = 604800), matching the nginx jails already in use on the server.
Laravel Octane
The package is safe under Octane out of the box: SignatureRegistry, SignatureGovernor,
SignatureDetector and Bot2BanLogger are singletons built from config once per worker (at bootstrap),
not on every request, and hold no per-request state in their properties (only readonly data parsed from
config). Bot2BanMiddleware has no properties of its own at all — all request data is local variables in
handle(). The client IP is read via $request->ip() of the specific request, not via $_SERVER/static
state. Verified with tooling: phpstan.neon has checkOctaneCompatibility: true enabled (Larastan's
OctaneCompatibilityRule) — 0 findings.
Two things worth keeping in mind specifically because of the long-lived worker:
- Only register custom signatures in a service provider's
boot()(Bot2Ban::register(...)), not in a controller/on every request — otherwise the signature registry will keep growing with every request within a worker until it's restarted. - If you change
laravel-bot2ban.*config at runtime (e.g. from an admin panel), already-resolved singletons (SignatureDetector/SignatureGovernor) won't pick up the change on their own — you needphp artisan octane:reload. This is standard Octane behavior for any config-driven singleton, not specific to this package.
Development
Locally (via Docker, so you don't need PHP/Composer on the host):
docker compose build docker compose run --rm php composer install docker compose run --rm php vendor/bin/pest docker compose run --rm php vendor/bin/pint --test docker compose run --rm php vendor/bin/phpstan analyse
If PHP and Composer are already installed locally — the same commands without
docker compose run --rm php: composer install, composer test, composer pint, composer phpstan.
CI (.github/workflows/tests.yml) runs all three checks on every push/PR.