ramir1/laravel-bot2ban

.

Maintainers

Package info

github.com/ramir1/laravel-bot2ban

pkg:composer/ramir1/laravel-bot2ban

Transparency log

Statistics

Installs: 57

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.0 2026-08-02 08:00 UTC

This package is auto-updated.

Last update: 2026-08-02 08:01:00 UTC


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

  1. A request passes through Bot2BanMiddleware.
  2. If the IP or URI matches the whitelist (config('laravel-bot2ban.whitelist')) — the request is passed through without any checks.
  3. 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.
  4. On a match — the event is written to a dedicated log channel (storage/logs/bot2ban-YYYY-MM-DD.log by default, rotated daily), and the request is terminated with the configured HTTP status code (418 for Block, 403 for Warning by default).
  5. 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/.loc setup) — 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 thrown HttpException while 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 in production).
  • 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 /horizon by default).
  • levels.warning / levels.blockenabled (whether to enforce/abort) and response_code for each level.
  • log_channel / log_path — where the log for fail2ban is written. The channel uses the daily driver, so the actual files are named bot2ban-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' bantime of 7 days). Rotation/cleanup needs neither cron nor logrotate on the server.
  • debug_mode — extra diagnostic messages via the app's standard logger (not the bot2ban channel).

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 need php 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.