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

Laravel H Captcha Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • The request key is fixed. The rule reads 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.
  • Tokens expire fast and are single-use. Roughly two minutes from issuance. A user who fills out a long form, gets a validation error on another field, and resubmits will fail captcha verification. This is the single most common bug report for every captcha package. Reset the widget on any failed submit.
  • Config caching bites during local setup. 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.
  • Two verify URLs exist. 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.
  • Decide fail-open vs fail-closed. If hCaptcha's API is unreachable, do you block the user or let them through? Default behavior blocks. For login and signup, blocking is right; for a newsletter signup, silently allowing through (with a log entry) is usually better than a 500. Wrap the rule call in a try/catch at the controller level if you need that policy.
  • CSP needs three hosts. Under a strict 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.
  • Widget options are passed through to the JS API. 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.
  • Vendor risk is real here. 21 stars and zero dependents means a small bus factor and no downstream battle-testing. Pin the version in 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.
  • Verify response has more than 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.
  • Don't render the widget twice. Loading 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.
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.
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
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource