msurguy/honeypot
msurguy/honeypot is a Laravel package for blocking form spam without CAPTCHA. It uses hidden honeypot fields and time-based traps to catch bots silently, with simple middleware, Blade helpers, and configurable validation for any form.
Install and wire it up
composer require msurguy/honeypot
On Laravel 5.5+ the service provider and Honeypot facade are auto-discovered. On older versions, register Msurguy\Honeypot\...\HoneypotServiceProvider in config/app.php and alias the facade.
Publish the config so you can see the knobs:
php artisan vendor:publish --provider="Msurguy\Honeypot\app\Providers\HoneypotServiceProvider"
That gives you config/honeypot.php with (roughly) honeypot_name, honeypot_time_name, and honeypot_time_threshold — the hidden field name, the timestamp field name, and the minimum number of seconds a human plausibly needs to fill the form.
Where to look first
config/honeypot.php — this is the whole configuration model. There is no per-form config, so understand that it is global before you design around it.vendor/msurguy/honeypot/ — it's short; read it once so you know exactly what status code / redirect you get on a block.First use case: a public contact form
Emit the fields inside the form:
<form method="POST" action="{{ route('contact.store') }}">
@csrf
@honeypot
{{-- your real fields --}}
</form>
Protect the route:
Route::post('/contact', [ContactController::class, 'store'])->middleware('honeypot');
(or reference the middleware class directly if no alias is registered). That's the whole integration — a two-line change to a form and one middleware on a route. It is genuinely a 10-minute install, which is the main reason to reach for it over a CAPTCHA.
Middleware on every public write endpoint. The natural fit is anything an anonymous visitor can POST to: contact, registration, comment, newsletter, password-reset request, guest checkout. Apply it per-route rather than globally so you don't accidentally trip authenticated API traffic or webhook receivers that don't have your form fields.
Route::middleware('honeypot')->group(function () {
Route::post('/contact', [ContactController::class, 'store']);
Route::post('/register', [RegisterController::class, 'store']);
});
Validation-rule style for FormRequests. If you prefer errors to surface in $errors alongside your other validation (so the user sees a normal "please try again" instead of a bare 403), validate inside the request instead of using the middleware:
public function rules(): array
{
return [
'email' => ['required', 'email'],
'honeypot' => ['honeypot'],
];
}
Check the installed version's README for the exact rule name; the concept is that it fails when the decoy field is non-empty or the elapsed time is below the threshold. This style composes better with FormRequest::failedValidation() and custom messages.
One guard per form, not per field. The fields are generated once per <form>. Put @honeypot at the top, inside the form, after @csrf.
Layer it with throttling. Honeypot catches naive bots; it does nothing against a determined attacker. Pair it with throttle:10,1 on the same route so a scripted attacker is rate-limited regardless:
Route::post('/contact', ...)->middleware(['honeypot', 'throttle:10,1']);
Inertia / Livewire / SPA forms. The Blade directive only exists in server-rendered Blade. For Inertia or React/Vue, render the same hidden inputs yourself using the config values, and make sure they survive the client-side submit:
<input type="text" name="{{ config('honeypot.honeypot_name') }}"
value="" tabindex="-1" autocomplete="off"
style="display:none" aria-hidden="true">
<input type="hidden" name="{{ config('honeypot.honeypot_time_name') }}"
value="{{ time() }}">
Alternatively call Honeypot::generateForm() from the server and pass it through as an HTML prop / raw string. For Livewire, the fields must be present on the initial render — components that re-render before submit can drop them.
Tests. In feature tests, either exclude the middleware ($this->withoutMiddleware(HoneypotMiddleware::class)) or post the fields explicitly with a timestamp far enough in the past that the time check passes:
$this->post('/contact', [
'email' => '[email protected]',
config('honeypot.honeypot_name') => '',
config('honeypot.honeypot_time_name') => time() - 60,
]);
Add a second test that does fill the honeypot field and asserts a 403/422, so a future refactor can't silently remove the protection.
Customising the rejection. If the default response is a bare 403, wrap the middleware or register your own that redirects back with an error bag — most product teams want "Your submission looked automated. Please try again." rather than an error page.
The time trap is the biggest source of false positives. A 4-second threshold sounds generous until you consider password managers, browser autofill, users who paste a prepared message, and Enter-key submits. If your support inbox gets "I couldn't submit the form" reports, raise honeypot_time_threshold before you blame anything else. 5–10 seconds is safer for long forms; 3 seconds is aggressive.
Browser autofill can populate the decoy field. This is the classic honeypot failure mode: the field is display:none, but autofill engines and some password managers still fill every text input they find. Always emit autocomplete="off", a name that doesn't look like a real field (my_name, website, company_url — not email2), and prefer the CSS-hidden text input over type="hidden" (sophisticated bots skip type="hidden" but fill visible-but-hidden inputs).
Add tabindex="-1" and aria-hidden="true". Without them, keyboard and screen-reader users can Tab straight into a field they can't see. If they type anything into it, they're permanently blocked with no explanation. Verify the directive's output includes these; if it doesn't, hand-write the markup.
Full-page or fragment caching breaks the model in both directions. A cached page means a stale timestamp — old timestamps pass the time check, so caching silently weakens the guard rather than breaking it. Conversely, if a CDN or edge worker strips unknown hidden inputs, the timestamp goes missing and legitimate users get blocked. Exclude forms from full-page caching, and if you use a fragment cache, check what survives.
Config is global. There is no per-form threshold or per-form field name. If your newsletter form needs 2 seconds and your 30-field application form needs 15, either set the threshold to the slowest case (accepting weaker protection on the fast forms) or instantiate your own checks around Honeypot::validate() with a locally overridden config value.
php artisan config:cache will bite you. Change honeypot_time_threshold, forget to re-run config:cache, and your deployed threshold is still the old value. Same for any env-driven override.
JSON / API clients don't get a useful error. The middleware's rejection is redirect-shaped. If the same route serves fetch() calls, either return JSON yourself in a custom middleware or make sure the client sends Accept: application/json so Laravel's exception handler negotiates correctly.
Log every block for the first two weeks. A one-line Log::info('honeypot blocked', ['ip' => $request->ip(), 'ua' => $request->userAgent()]) inside your middleware wrapper tells you immediately whether you're blocking bots or real customers. This is the single highest-value thing you can add to a package this simple.
Field-name collisions are silent. If a real field in your form happens to share a name with honeypot_name, every submission fails the "decoy must be empty" check. Rename the decoy field in config to something obviously synthetic (hp_website_url).
Don't treat it as a security control. It stops form-filling scripts, not targeted abuse. Keep it off login and other auth-sensitive endpoints (where a false positive locks a real user out), and pair it with rate limiting, CSRF, and — for high-value forms — a real challenge.
Extension points are limited but sufficient. Publish the config, swap the Blade directive for hand-written markup when you need accessibility or JS-framework support, wrap the middleware to customise the response, and call the facade's validate method directly if you want the check inside a service class rather than the HTTP layer. Beyond that, the package is intentionally small — if you need per-form state or expiry tokens you're better off writing 30 lines yourself.
How can I help you explore Laravel packages today?