getodden / botguard
Progressive bot protection for Laravel forms: honeypot, timing, rate limits, soft scoring, and Cloudflare Turnstile escalation.
Requires
- php: ^8.2
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- illuminate/view: ^10.0|^11.0|^12.0|^13.0
- laravel/framework: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.14
- laravel/pint: ^1.32
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Progressive bot protection for Laravel forms. Invisible checks run on every submission; a Cloudflare Turnstile challenge appears only when a submission looks suspicious, or when a form is being attacked.
submission
-> hard blocks per-IP rate limit, honeypot filled -> 403 / 429
-> soft signals bad or replayed token, too fast, bot-like headers,
link spam, repeats, per-email rate limit ... -> score
-> score >= 5, or form under attack, or always_challenge mode -> Turnstile
-> otherwise -> allowed, no friction
Install
composer require getodden/botguard
php artisan vendor:publish --tag=botguard-config # optional
For a local checkout, add a path repository to your app's composer.json:
"repositories": [{ "type": "path", "url": "../botguard" }]
Requires PHP 8.2+ and Laravel 12 or 13 (the test suite passes on both). The service provider is auto-discovered.
Use
Add the fields inside your form:
<form method="POST" action="/contact"> @csrf <x-botguard::fields form="contact" /> ... </form>
Protect the route, using the same form name:
Route::post('/contact', ContactController::class)->middleware('botguard:contact');
That is the whole setup for the invisible layers. The form name keeps tokens, rate limits and attack state separate per form.
Turnstile escalation
TURNSTILE_SITE_KEY=... TURNSTILE_SECRET_KEY=...
Without these keys, a submission whose score reaches the threshold is blocked outright: real browsers carry a valid token and score low, so only scripted clients are affected. Challenges forced by always_challenge mode or an attack cannot run without keys and let the submission through. With keys:
- A submission that scores 5 or more is sent back with the Turnstile widget shown (web), or gets a
422withbotguard.challenge: trueand the sitekey (JSON). - The widget is rendered automatically by
<x-botguard::fields>after a challenge, when the form is under attack, or inalways_challengemode. Force it with:challenge="true".
Modes
BOTGUARD_MODE=progressive # off | progressive | always_challenge
Per route: ->middleware('botguard:login,always_challenge'). Pass the same mode to the component so the widget shows on first render: <x-botguard::fields form="login" mode="always_challenge" />. An unknown mode throws InvalidArgumentException.
Aggressive bots
When attack.threshold distinct IPs (default 10) are blocked or fail a challenge on one form within 10 minutes, that form requires Turnstile for everyone for 15 minutes. Sources are counted per /24 (IPv4) or /48 (IPv6), so a single client rotating addresses cannot trigger it. Tune or disable it under attack in the config.
JSON / SPA forms
Issue a fresh token per submission with app(\Odden\BotGuard\Support\TokenSigner::class)->issue('contact') (tokens are single use), send _bg_token, _bg_js (the nonce reversed, set by the component's script) and an empty _bg_hp. On a 422 with botguard.challenge, render Turnstile with the returned sitekey and resubmit with cf-turnstile-response.
What each guard does
| Guard | Effect |
|---|---|
RateLimitGuard |
Blocks by IP (per /64 for IPv6); scores an email submitted too often |
HoneypotGuard |
Blocks if the hidden field is filled |
TimingGuard |
Scores submissions faster than 2s, missing/forged/expired/replayed tokens and a missing JS marker |
RequestHeadersGuard |
Scores missing or tool-like User-Agent, missing Accept-Language |
DisposableEmailGuard |
Scores throwaway email domains |
LinkCountGuard |
Scores link-heavy messages |
IpRangeGuard |
Scores CIDR ranges you list as suspicious |
RepeatedContentGuard |
Scores the same message text arriving from three or more different networks |
Every weight and threshold is in config/botguard.php.
Custom guards
use Illuminate\Http\Request; use Odden\BotGuard\Contracts\Guard; use Odden\BotGuard\GuardResult; class NoCompetitorsGuard implements Guard { public function evaluate(Request $request, string $form): GuardResult { return str_contains((string) $request->input('email'), '@competitor.com') ? GuardResult::signals(['competitor_email' => 5]) // add to the score : GuardResult::pass(); // or GuardResult::block('reason') } }
Add the class to botguard.guards. To plug in another challenge provider later, replace TurnstileVerifier in the container.
Use it without the middleware
$verdict = app(\Odden\BotGuard\BotGuard::class)->inspect($request, 'signup'); $verdict->allowed(); // proceed $verdict->needsChallenge(); $verdict->blocked(); $verdict->reasons; // ['ua_automation', 'score_threshold', ...]
After the middleware runs, the verdict is available as $request->attributes->get('botguard').
Operations
- Logging. Blocked and challenged submissions are logged with form name, reasons, score, user agent, and IP (
log.include_ip). Form input is never logged. Listen toOdden\BotGuard\Events\BotGuardEvaluatedfor metrics. - Cache. Rate limits, replay detection and attack state use the cache. Behind more than one server, use a shared store (
BOTGUARD_CACHE_STORE=redis). - Fail-open vs fail-closed. If a challenge is needed but Cloudflare is unreachable or returns a 5xx, or the secret is rejected,
challenge.unavailable_actiondecides:allow(default, avoids losing real leads) orblock. Any other reply from Cloudflare counts as a failed challenge. Without keys, see Turnstile escalation above. - Page caching. Tokens are single use and expire after 6 hours. A cached page serves the same token to every visitor, so each one after the first scores as a replay and is challenged. Do not cache pages that contain
<x-botguard::fields>. - Proxies. Rate limits and IP checks rely on
$request->ip(). Configure Laravel's trusted proxies for your load balancer, and never trust forwarded headers from the open internet: a spoofed address would defeat the rate limits andbypass.ips. - Privacy. There is no device fingerprinting. The honeypot, signed token and rate limits store only keyed hashes (HMAC) of the network and email in the cache, never the raw values. The IP is logged only if you set
log.include_ip. Turnstile is a third-party request from the visitor's browser, so mention it in your privacy notice.
Limits
The invisible layers stop scripted and careless bots. A bot driving a real headless browser can pass them, which is exactly when the Turnstile escalation matters. Turnstile is not perfect either; for high-value flows (signup with free trials, payment), add account-level controls such as email verification.
Tests
composer install
composer test
License
MIT