aryehraber/statamic-recaptcha
Protects Statamic forms from spam with Google reCAPTCHA v2 and v3 integration. Adds configurable captcha fields, server-side verification, and error handling to keep submissions clean.
composer require (or bump to ^2.8.4 if already using the package) and publish the config/views: php artisan vendor:publish --tag=captcha-config (and --tag=captcha-views if you plan to restyle). The package is auto-discovered, so no manual service provider registration is needed on Laravel 9+.config/captcha.php — driver (image, math, recaptcha/turnstile), length, case sensitivity, expiry, fonts, and the image route/width/height all live there. Second stop: the package's validation rule (typically registered as captcha), since that is the contract your FormRequests will use.// Blade
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<img src="{{ captcha_src() }}" alt="captcha">
<input type="text" name="captcha" autocomplete="off">
@error('captcha') <span class="text-red-600">{{ $message }}</span> @enderror
<button>Send</button>
</form>
$request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email'],
'message' => ['required', 'string', 'max:2000'],
'captcha' => ['required', 'captcha'],
]);
composer update and php artisan config:clear / view:clear after pulling.One captcha per form, keyed per instance. The reliable pattern for multi-form pages is to give each instance its own identifier and pass it through the request, so the validator reads the correct session entry:
// Controller / view composer — one key per form instance
$loginKey = captcha_src('login'); // unique id scopes the session entry
$signupKey = captcha_src('signup');
// Validation — validate the answer against the matching key
$request->validate([
'captcha' => ['required', 'captcha:' . $request->input('captcha_key')],
]);
Keep the key in a hidden input. Before 2.8.4 the keys collided and the second image on the page invalidated the first; on 2.8.4 the scoping works as intended, so prefer this over separate routes or separate pages.
FormRequest + custom message. Centralise the rule so error copy stays consistent and translatable:
class ContactRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email'],
'captcha_key' => ['required', 'string'],
'captcha' => ['required', 'captcha:' . $this->input('captcha_key')],
];
}
public function messages(): array
{
return ['captcha.captcha' => __('validation.captcha')];
}
}
SPA / API integration. Image-route captchas depend on the session, so decide deliberately:
withCredentials: true / axios.defaults.withCredentials = true), read the image + key from JSON, and submit both. Session cookie and CSRF token must be in place or every answer fails validation.Testing. Captchas make feature tests flaky. Disable or fake the driver in the test environment (CAPTCHA_DRIVER=null or a config override in phpunit.xml), or have a dedicated test that pulls the current session value and submits it. Never assert on the generated image content.
Layering with other defences. Captcha is a last line, not a first one — pair it with throttle: middleware on the POST route, a honeypot field, and time-to-submit checks. For reCAPTCHA v3, set the score threshold in config and log near-threshold rejections instead of silently failing, so you can tune it.
Customisation and extension. Publish the views to restyle without forking. Swap the generator by binding a subclass that implements the package's generator contract in a service provider ($this->app->bind(...)) — useful for branded distortion, custom fonts, or an audio alternative. Driver choice should come from config('captcha.driver') fed by env, so staging can run a no-op driver.
array or cookie session drivers have no server-side store, so the generated answer is never retrievable and validation always fails. Confirm SESSION_DRIVER is file/redis/database and that the image request and the POST hit the same domain, scheme, and session cookie name. Behind a load balancer without sticky sessions, use a shared session store.< 2.8.4 you get cross-talk — one image's answer overwrites the other's, producing random captcha validation errors that are near-impossible to reproduce. Upgrading is the fix. After upgrading, also clear compiled config and views, otherwise the old published view/config can mask the change.Cache-Control: no-store to the captcha image response, or users will be shown a stale image whose answer is no longer in session.bail before captcha means the captcha never runs when an earlier field fails — usually what you want (users don't get a fresh captcha for an unrelated typo), but be aware the captcha is consumed/re-rendered only on the next request, so the user may need to re-read the image after a validation round-trip. Design your form to refresh the image on 419/validation errors.php artisan route:list | grep -i captcha (route registered?), inspect the session payload for the captcha key, confirm config('captcha.driver') resolves as expected after php artisan config:clear, and check that the hidden key input is actually posted (a common miss when forms are partially re-rendered by JS).alt text, and avoid disabling the submit button in ways that block screen-reader users from completing the form.^2.8.4 in composer.json to guarantee the multi-instance fix across environments — composer.lock drift between dev and prod is a classic source of "works locally, always fails in prod" captcha reports.How can I help you explore Laravel packages today?