buzz/laravel-h-captcha
Laravel integration for hCaptcha: a validation rule, Blade rendering, and publishable config to protect forms from bots and spam. Verifies hCaptcha tokens server-side with minimal setup and fits standard Laravel validation and request flows.
Install and wire up in four steps.
composer require thinhbuzz/laravel-h-captcha
php artisan vendor:publish --provider="Thinhbuzz\HCaptcha\HCaptchaServiceProvider"
Laravel's package discovery registers Thinhbuzz\HCaptcha\HCaptchaServiceProvider automatically; the publish step drops config/hcaptcha.php into your app. That config file is the first thing to read — it tells you the exact env keys, the widget options array, and the verification endpoint the package posts to.
HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
HCAPTCHA_SECRET_KEY=0x0000000000000000000000000000000000000000
Those two values are hCaptcha's public test keys — they always render a widget and always pass verification. Use them locally and in CI so you never hit the live service from a dev machine, then swap in real keys from the hCaptcha dashboard per environment.
Your first use case: a contact form. Two touch points — render the widget inside the <form>, and add the package's validation rule to the request:
<form method="POST" action="/contact">
@csrf
<input name="email" type="email">
<textarea name="message"></textarea>
{!! HCaptcha::display() !!}
<button type="submit">Send</button>
</form>
$validated = $request->validate([
'email' => 'required|email',
'message' => 'required|string|max:2000',
'h-captcha-response' => 'required|hcaptcha',
]);
If the token is missing, expired, or already used, the hcaptcha rule fails and Laravel redirects back with the errors bag populated — no controller branching required. That's the whole day-one path.
Where to look first, in order: config/hcaptcha.php → the HCaptcha facade class (to see what rendering helpers exist) → the validation rule class (to see which request key it reads). The README covers the happy path; the rule and facade classes are small enough to read in five minutes and answer every edge-case question faster than searching.
Form Requests instead of inline rules. Put 'h-captcha-response' => ['required', 'hcaptcha'] in a StoreContactRequest so the rule travels with the rest of the validation. Because the rule resolves through the container, it works identically in a FormRequest, a closure validator, or Validator::make().
Invisible / passive mode. hCaptcha can run without a visible checkbox. Add size => 'invisible' (plus a data-callback if you need one) to the options passed at render time, then submit the form programmatically from the callback. This is the pattern for login and signup where a visible widget hurts conversion — but it changes the UX contract, so test keyboard-only flow.
Protecting routes with middleware. For low-traffic internal forms, wrapping in a route middleware that verifies the posted token is cleaner than repeating the rule. Register it in bootstrap/app.php (Laravel 11+) or Kernel.php, and scope it to POST routes only. Keep the validation-rule approach for anything user-facing where you want per-field error messages.
Livewire / Inertia / Alpine. The widget renders client-side DOM that these tools don't know about. After a failed submit, the token is consumed — you must reset the widget before the user retries, otherwise every subsequent attempt fails with an expired-token error. For Livewire, hook wire:submit to reset after each response; for Inertia, do a full form reset on validation error.
Multi-step forms and multiple widgets. A token is single-use. If step 1 validates and step 2 re-validates the same POST payload, step 2 fails. Verify once at the final submit, or render a fresh widget per step.
API / SPA endpoints. Send the token as h-captcha-response in the JSON body. Validate it in the same controller action as the rest of the payload; the rule's error message surfaces cleanly through Laravel's 422 JSON response if you use $request->validate().
Testing. Point HCAPTCHA_SECRET_KEY at the test secret in phpunit.xml so the real verifier accepts the test token without network mocking. For unit tests that must not touch the network, bind a fake into the container:
$this->mock(HCaptcha::class, fn ($m) => $m->shouldReceive('verify')->andReturn(true));
Integration with auth scaffolding. Breeze/Fortify/Jetstream own their login controllers. Rather than editing vendor-published controllers, add the rule via a LoginRequest override or a Fortify::authenticateUsing() closure that validates the captcha before delegating to the credential check. This keeps upgrades painless.
h-captcha-response (hCaptcha's default field name). Renaming the input in Blade breaks validation silently — you'll get "the captcha is required" with a widget visibly on screen. If you must rename, pass the field name to the rule explicitly or map it in a middleware.php artisan config:cache snapshots .env values. After changing HCAPTCHA_SITE_KEY, run php artisan config:clear or the widget will keep loading the old sitekey (or render nothing at all). Symptom: an empty div where the checkbox should be.https://hcaptcha.com/siteverify for standard, https://api.hcaptcha.com/siteverify for Enterprise. The config exposes the URL — override it per environment if you're on Enterprise, and be aware that an outbound firewall blocking hCaptcha returns a connection error, not a success: false, so your rule may throw rather than fail validation.Content-Security-Policy the widget renders blank until you allow https://js.hcaptcha.com and https://newassets.hcaptcha.com in script-src, and https://hcaptcha.com / https://newassets.hcaptcha.com in frame-src and connect-src. Blank widget + clean network tab = missing CSP directive.theme, size, hl, tabindex all work; put them in config/hcaptcha.php rather than inline so dark-mode or localisation changes are one edit, not a grep across Blade files.composer.json (avoid ^ wildcards on a package this young), and keep the two integration points — the Blade render call and the validation rule — behind a thin internal helper or interface. If the package stalls, swapping to a maintained fork or a hand-rolled Http::post() verifier is then a single-file change.success. The payload includes score, hostname, challenge_ts, and error-codes. If you want risk-based behaviour (block only low scores), inspect score yourself after the rule passes — the rule itself is boolean. Log error-codes on failure; it distinguishes "expired token" from "invalid secret" from "duplicate submission" and saves a lot of debugging time.HCaptcha::display() in a layout and again in a partial causes the script to initialise two widgets on the same sitekey; the second silently hijacks the first's callback. Keep rendering co-located with the form.How can I help you explore Laravel packages today?