How it works
A honeypot is a form field that visitors never see and bots fill in. A time trap refuses a form that comes back faster than a person can type. This package combines both.
Which checks run, and in which order
A submission passes these checks, in this order. The first one that fails stops the submission with a validation error and a SpamBlocked event.
| Check | Fails when | Message the visitor sees | Event reason |
|---|---|---|---|
| Bait field | the hidden field was filled in, or was not submitted at all | Spam detected. | FIELD_FILLED |
| Payload | the start time is missing, or a plain form’s token is missing, malformed or wrongly signed | Spam detected. | INVALID_PAYLOAD |
| Time trap | fewer than minimum_fill_seconds (default 5) passed since the form was loaded | Form submitted too quickly. | SUBMITTED_TOO_QUICKLY |
| Expiry (plain forms only) | more than maximum_fill_seconds passed, one day by default | This form has expired. Please try again. | EXPIRED |
A submission at exactly minimum_fill_seconds passes. The messages are translated; Configuration lists the keys.
The bait field
Bots that fill in every input also fill in the bait. Visitors never see it:
- It is hidden like a screen-reader-only element (1×1 px, clipped, through a nonced style block when the site has a Content Security Policy) and marked
aria-hiddenwithtabindex="-1", so keyboard and screen reader users skip it too. It is not moved off screen with something likeleft:-10000px, a pattern a bot can look for. - Its name is generated, for example
referral_3f9a. A fixed name likehp_websiteis simple for a bot to skip, and a name with “website”, “email” or “company” in it gets filled in by browser autofill. That would block a real visitor. - It carries
autocomplete="off"and the attributes that ask password managers to skip a field:data-1p-ignore(1Password),data-lpignore="true"(LastPass),data-bwignore(Bitwarden) anddata-form-type="other"(Dashlane).
The start time
Where the start time lives decides whether a bot can fake it.
- Livewire:
hp_started_atandhp_tokenare#[Locked]properties. They live in the component snapshot that Livewire checksums, and a request that changes them is rejected. - Plain forms: the time is inside
hp_tokenasrandom.timestamp.signature, signed with HMAC-SHA256 and yourAPP_KEY. A changed timestamp breaks the signature. Without anAPP_KEYthe package throws Laravel’sMissingAppKeyExceptioninstead of signing with an empty key.
When you rotate APP_KEY, put the old key in APP_PREVIOUS_KEYS in .env. Forms that were open during the rotation then still validate.
Expiry
A token in a plain form is part of the HTML, so a bot can load the page once and send the same token again and again. Plain forms therefore expire after maximum_fill_seconds, one day by default. A visitor who submits an older form gets “This form has expired. Please try again.” and a fresh token on the next page load.
Livewire forms don’t expire. Their start time is protected by Livewire’s checksum instead, and a visitor who leaves a tab open overnight would otherwise lose what they typed.
What it does not stop
A honeypot stops cheap, automated spam. It does not stop:
- a person typing spam by hand;
- a bot that runs a real browser, skips hidden fields and waits a few seconds;
- a bot that loads the form once and submits it many times. A plain form’s token stays valid until it expires, and a Livewire form has no expiry.
Add rate limiting for that last case. For a plain form, use Laravel’s throttle middleware on the route that receives the form. routes/web.php:
Route::post('/contact', [ContactController::class, 'store'])->middleware('throttle:5,1');
A Livewire form submits to Livewire’s own update route, so middleware on your page route does not count its submits. Use Laravel’s RateLimiter inside the action instead:
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
public function submit(): void
{
$key = 'contact-form:'.request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages(['email' => 'Too many messages. Try again in a minute.']);
}
RateLimiter::hit($key, 60);
$this->validateHoneypot();
// ...
}
When that is not enough, add a CAPTCHA such as Cloudflare Turnstile on top. Compared to alternatives helps you choose.