Search by

getodden / botguard

do-io

Progressive bot protection for Laravel forms: honeypot, timing, rate limits, soft scoring, and Cloudflare Turnstile escalation.

Package info

github.com/getodden/botguard

pkg:composer/getodden/botguard

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-11-05 16:58 UTC

This package is auto-updated.

Last update: 2026-11-05 16:59:45 UTC


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 422 with botguard.challenge: true and the sitekey (JSON).
  • The widget is rendered automatically by <x-botguard::fields> after a challenge, when the form is under attack, or in always_challenge mode. 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 to Odden\BotGuard\Events\BotGuardEvaluated for 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_action decides: allow (default, avoids losing real leads) or block. 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 and bypass.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