Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Honeypot Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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

  1. The README — the package is tiny, and the entire surface is: a Blade directive / facade method to emit the fields, plus a way to validate them.
  2. 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.
  3. The middleware class in 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.

Implementation Patterns

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console