Plain forms and controllers
Outside Livewire, <x-honeypot /> renders the hidden bait field and a hidden hp_token input. The token holds the time the form was rendered, signed with your APP_KEY. In the controller, HoneypotService::validate() checks both.
A complete contact form
routes/web.php:
use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;
Route::view('/contact', 'contact');
Route::post('/contact', [ContactController::class, 'store'])->middleware('throttle:5,1');
throttle:5,1 is Laravel’s rate limiter: at most five submits per minute per visitor. The package itself does not limit how often a form is sent.
resources/views/contact.blade.php:
<form method="POST" action="/contact">
@csrf
<input type="email" name="email">
@error('email') <p>Enter a valid email address.</p> @enderror
<x-honeypot />
<button type="submit">Send</button>
@if (session('status'))
<p>Thank you for your message.</p>
@endif
</form>
<x-honeypot /> must be inside the <form> tag, otherwise the browser does not send its fields. It also shows the honeypot error message.
app/Http/Controllers/ContactController.php:
<?php
namespace App\Http\Controllers;
use Darvis\LivewireHoneypot\Services\HoneypotService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function store(Request $request, HoneypotService $honeypot): RedirectResponse
{
$honeypot->validate($request->all());
$data = $request->validate(['email' => 'required|email']);
// Send the mail or store $data here.
return back()->with('status', 'sent');
}
}
validate() throws a ValidationException when a check fails. Laravel then redirects back to the form, and <x-honeypot /> shows the message. A JSON request gets a 422 response. The code below validate() does not run for a blocked submission.
Version 1.5.1 or older
validate($request->all()) works since 1.6.0. A default Laravel application runs the ConvertEmptyStringsToNull middleware, which turns the empty bait field into null. Since 1.6.0 a bait that is present but null counts as empty; a bait that is absent is still rejected. On 1.5.1 or older every real submission gets “Spam detected.”: upgrade, or pass array_map(fn ($value) => $value ?? '', $request->all()) instead.
Where the error appears
The error is reported under the field_name key, hp_website by default. <x-honeypot /> reads that key and shows the message in <p class="hp-error" role="alert">. Don’t add your own @error('hp_website'), or the message appears twice.
A different minimum time for one form
$honeypot->validate($request->all(), minimumSeconds: 10);
Without the second argument the minimum_fill_seconds setting applies.
Expiry and key rotation
- A plain form expires after
maximum_fill_seconds, one day by default. The token is part of the HTML, so without expiry a bot could copy it once and reuse it forever. A visitor who submits an expired form sees “This form has expired. Please try again.” and gets a fresh token when the page reloads. SetHONEYPOT_MAXIMUM_FILL_SECONDS=0to turn expiry off. - When you rotate
APP_KEY, put the old key inAPP_PREVIOUS_KEYSin.env. Laravel reads that variable intoapp.previous_keys, and the package accepts tokens signed with those keys. Forms that were open during the rotation then still validate.
Page caching
The token holds the time the page was rendered. When a full page is cached, every visitor gets the same old token. Two things follow:
- The time check always passes, because the token looks older than
minimum_fill_seconds. The bait field still works. - Once the cached page is older than
maximum_fill_seconds, every visitor gets “This form has expired. Please try again.”.
Exclude pages with a form from the page cache, or build the form as a Livewire component.
A plain form inside a Livewire component
A form in a Livewire view that posts to a controller, such as a newsletter signup in a footer component, also gets the plain variant. <x-honeypot /> switches to Livewire mode only when the component that renders it uses the HasHoneypot trait.
Rendering the inputs yourself
When you can’t use the Blade component, for example in an Inertia or JavaScript form, ask the service for the values:
use Darvis\LivewireHoneypot\Services\HoneypotService;
$fields = app(HoneypotService::class)->generate();
// ['hp_website' => '', 'hp_started_at' => 1789657867, 'hp_token' => 'Xy3k….1789657867.9f2c…']
- Pass
$fieldsto your page. - Render the bait as a text input named
hp_website(thefield_namesetting) and hide it with CSS. Leave its value empty. - Send
hp_tokenback unchanged, together with the bait. - Validate in the controller exactly as in the example above.
hp_started_at is only there for backward compatibility. The service reads the start time from the signed token and ignores this field.
The default name hp_website contains “website”, a word browser autofill recognises. When you render the input yourself, set HONEYPOT_FIELD_NAME to a word autofill does not know, such as remarks_field. Avoid “website”, “url”, “email”, “name”, “phone” and “company”. Your honeypot may be blocking real visitors explains why.