labidi / recaptcha
Google reCAPTCHA (v2 checkbox, v2 invisible and v3) integration for Laravel, with Blade and Inertia.js support.
Requires
- php: ^8.3
- illuminate/contracts: ^12.40 || ^13.0
- illuminate/http: ^12.40 || ^13.0
- illuminate/routing: ^12.40 || ^13.0
- illuminate/support: ^12.40 || ^13.0
- illuminate/validation: ^12.40 || ^13.0
- illuminate/view: ^12.40 || ^13.0
Requires (Dev)
- inertiajs/inertia-laravel: ^2.0 || ^3.0
- larastan/larastan: ^3.0
- laravel/pint: ^1.20
- orchestra/testbench: ^10.8 || ^11.0
- pestphp/pest: ^4.2 || ^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-24 19:15:02 UTC
README
Protect Laravel forms with Google reCAPTCHA v3, v2 checkbox or v2 invisible, in Blade views and in Inertia.js apps built with Vue or React. You configure everything in config/recaptcha.php, so switching between versions is a one-line .env change.
- A validation rule, a route middleware and a facade for verifying tokens on the server
- Blade components and directives that render the right widget for the configured version
- Inertia support: settings are shared as a page prop, and the package ships a Vue composable and a React hook
- Per-action score thresholds, action and hostname checks, and token freshness checks for v3
- A testing fake, so your test suite never calls Google
Requirements
| Supported | |
|---|---|
| PHP | 8.3, 8.4, 8.5 |
| Laravel | 12.40+ and 13.x |
| Inertia (optional) | inertiajs/inertia-laravel 2.x and 3.x, with Vue 3 or React |
Installation
composer require labidi/recaptcha
The service provider and the Recaptcha facade are registered automatically.
Create a key pair for the reCAPTCHA type you want, then add the keys to .env:
RECAPTCHA_VERSION=v3 # v3, v2-checkbox or v2-invisible RECAPTCHA_SITE_KEY=your-site-key RECAPTCHA_SECRET_KEY=your-secret-key
Keys only work with the reCAPTCHA type they were created for. A v3 key cannot render a v2 checkbox.
To customise the rest of the settings, publish the config file:
php artisan vendor:publish --tag=recaptcha-config
Blade
Load the script once in your layout, then add a field to each protected form:
<head> <x-recaptcha::script /> </head> <form method="POST" action="/login"> @csrf <input type="email" name="email"> <input type="password" name="password"> <x-recaptcha::field action="login" /> @error('g-recaptcha-response') <p>{{ $message }}</p> @enderror <button type="submit">Log in</button> </form>
<x-recaptcha::field> renders whatever RECAPTCHA_VERSION asks for:
| Version | What it renders |
|---|---|
v3 |
A hidden input. A fresh token for the given action is fetched when the form is submitted, so it never expires on a slow form. |
v2-checkbox |
The "I'm not a robot" checkbox. |
v2-invisible |
The invisible widget. The challenge runs when the form is submitted, and the form continues once it passes. |
Every component has a directive twin for places where tags are awkward:
| Component | Directive |
|---|---|
<x-recaptcha::script /> |
@recaptchaScript |
<x-recaptcha::field action="login" /> |
@recaptchaField('login') |
<x-recaptcha::widget theme="dark" /> |
@recaptchaWidget(theme: 'dark') |
<x-recaptcha::field> accepts action, theme, size, badge, tabindex, id and nonce, and passes other attributes such as class through to the element. <x-recaptcha::widget> is the same component, restricted to v2. It throws a clear error if it is used with a v3 key.
Content Security Policy. Inline scripts get a nonce from, in order: the nonce attribute, attributes.nonce in the config, and then the nonce generated by Laravel's Vite::useCspNonce().
To change the markup, publish the views with php artisan vendor:publish --tag=recaptcha-views.
Verifying tokens
Use whichever style fits the route. All three give the same result.
Validation rule
use Labidi\Recaptcha\Rules\RecaptchaRule; $request->validate([ 'email' => ['required', 'email'], 'g-recaptcha-response' => [new RecaptchaRule('login')], ]);
The rule object gives specific error messages, such as a low score or an expired token. You can also write it as Recaptcha::rule('login') or as the string 'recaptcha:login'. The string rule always uses the generic "failed" message.
The rule runs even when the field is missing, so a bot cannot skip verification by leaving the token out.
Middleware
Route::post('/login', LoginController::class)->middleware('recaptcha:login'); Route::post('/contact', ContactController::class)->middleware('recaptcha'); // no action check
A failed verification throws a ValidationException, so each client gets the response it expects:
- Blade forms get a redirect back with errors.
- Inertia visits get a redirect back, with the errors in
form.errors. - JSON and API clients get a
422response.
Clients can send the token in the X-Recaptcha-Token header instead of a form field. The route can read the verification result from $request->attributes->get('recaptcha').
Manual verification
use Labidi\Recaptcha\Facades\Recaptcha; $response = Recaptcha::verify($token, action: 'login', ip: $request->ip()); if ($response->isFailure()) { logger()->info('reCAPTCHA failed', $response->errorCodes()); } $response->score(); // 0.0 – 1.0 (v3), null for v2 $response->action(); // e.g. "login" (v3) Recaptcha::verifyOrFail($token, 'login'); // throws RecaptchaException on failure
If Google is unreachable or returns a bad response, verification fails with a connection-failed or invalid-json error code. It does not throw an exception, so a Google outage never causes a 500 on your forms.
Inertia.js (Vue & React)
Once Inertia is installed, there is nothing to set up on the server. The package adds its middleware to the web group and shares the public settings with every page as a recaptcha prop. The secret key is never shared.
// usePage().props.recaptcha { enabled: true, version: 'v3', site_key: '6Lc…', script_url: 'https://www.google.com/recaptcha/api.js?render=6Lc…&onload=…', field_name: 'g-recaptcha-response', theme: 'light', size: 'normal', badge: 'bottomright', language: null, }
Publish the front-end helpers into resources/js/vendor/recaptcha:
php artisan vendor:publish --tag=recaptcha-js
This publishes plain ES modules with TypeScript declarations. They need no build step of their own, because your app's Vite bundles them.
Protect the route on the server exactly as you would for Blade, with the recaptcha:login middleware or the validation rule.
Vue 3
<script setup> import { useForm } from '@inertiajs/vue3'; import { useRecaptcha } from '@/vendor/recaptcha/vue'; const { container, execute, reset, fieldName } = useRecaptcha({ action: 'login' }); const form = useForm({ email: '', password: '' }); async function submit() { const token = await execute(); form .transform((data) => ({ ...data, [fieldName.value]: token })) .post('/login', { onError: () => reset() }); } </script> <template> <form @submit.prevent="submit"> <input v-model="form.email" type="email"> <input v-model="form.password" type="password"> <!-- v2 only: the checkbox or invisible widget renders here --> <div ref="container" /> <p v-if="form.errors[fieldName]">{{ form.errors[fieldName] }}</p> <button :disabled="form.processing">Log in</button> </form> </template>
React
import { useForm } from '@inertiajs/react'; import { useRecaptcha } from '@/vendor/recaptcha/react'; export default function Login() { const { containerRef, execute, reset, fieldName } = useRecaptcha({ action: 'login' }); const { data, setData, post, transform, processing, errors } = useForm({ email: '', password: '' }); async function submit(event) { event.preventDefault(); const token = await execute(); transform((data) => ({ ...data, [fieldName]: token })); post('/login', { onError: () => reset() }); } return ( <form onSubmit={submit}> <input type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} /> <input type="password" value={data.password} onChange={(e) => setData('password', e.target.value)} /> {/* v2 only: the checkbox or invisible widget renders here */} <div ref={containerRef} /> {errors[fieldName] && <p>{errors[fieldName]}</p>} <button disabled={processing}>Log in</button> </form> ); }
The same component code works for every version:
- With v3,
execute()fetches a fresh token for the action. - With v2 checkbox, it returns the checkbox response.
- With v2 invisible, it runs the challenge and resolves once it passes.
- When reCAPTCHA is disabled, it resolves to
null.
Call reset() after a failed submit, because each token can only be used once.
Both helpers return { config, enabled, fieldName, ready, error, execute, reset }, plus the container ref (container in Vue, containerRef in React). They also accept an options.prop if you renamed the shared prop, and options.config to pass settings explicitly.
Framework-agnostic module
recaptcha.js exports the building blocks that the hooks use: loadRecaptcha(config), executeRecaptcha(config, action), renderRecaptcha(config, element, options), resetRecaptcha(id), getRecaptchaToken(config, { action, widget }) and isRecaptchaEnabled(config).
These helpers:
- load
api.jsonce, even across Inertia page visits; - reuse the script if
@recaptchaScriptalready loaded it inapp.blade.php; - do nothing during SSR.
Configuration reference
Every option can be set from .env:
| Key | Env | Default | Purpose |
|---|---|---|---|
enabled |
RECAPTCHA_ENABLED |
true |
When false, every check passes and nothing renders. |
version |
RECAPTCHA_VERSION |
v3 |
v3, v2-checkbox or v2-invisible. |
site_key |
RECAPTCHA_SITE_KEY |
— | Public key, sent to the browser. |
secret_key |
RECAPTCHA_SECRET_KEY |
— | Private key, used only on the server. |
verify_url |
RECAPTCHA_VERIFY_URL |
google.com siteverify | See Regions where google.com is blocked. |
script_url |
RECAPTCHA_SCRIPT_URL |
google.com api.js |
Same as above, for the script. |
http.timeout / connect_timeout |
RECAPTCHA_TIMEOUT / RECAPTCHA_CONNECT_TIMEOUT |
5 / 3 s |
Timeouts for requests to Google. |
http.retries / retry_delay |
RECAPTCHA_RETRIES / RECAPTCHA_RETRY_DELAY |
1 / 100 ms |
Retries after a connection failure. A request that reached Google is never resent, because tokens are single-use. |
score.threshold |
RECAPTCHA_SCORE_THRESHOLD |
0.5 |
Minimum v3 score. |
score.actions |
— | [] |
Per-action thresholds, e.g. ['login' => 0.7]. |
verify.action |
RECAPTCHA_VERIFY_ACTION |
true |
Reject v3 tokens issued for a different action. |
verify.hostname |
RECAPTCHA_VERIFY_HOSTNAME |
false |
Reject tokens whose hostname differs from APP_URL. |
verify.challenge_timeout |
RECAPTCHA_CHALLENGE_TIMEOUT |
null |
Reject tokens older than this many seconds. |
field_name |
RECAPTCHA_FIELD_NAME |
g-recaptcha-response |
Name of the v3 input. v2 always uses Google's name. |
attributes.* |
RECAPTCHA_THEME, RECAPTCHA_SIZE, RECAPTCHA_BADGE, RECAPTCHA_LANGUAGE |
light, normal, bottomright, browser language |
Widget defaults, plus nonce, async and defer for the script. |
inertia.share |
RECAPTCHA_INERTIA_SHARE |
true |
Share settings with Inertia pages. |
inertia.prop |
RECAPTCHA_INERTIA_PROP |
recaptcha |
Name of the shared prop. |
skip_environments |
— | ['testing'] |
Environments where verification always passes. |
skip_ips |
— | [] |
IP addresses or CIDR ranges that skip verification, e.g. for internal QA. |
Regions where google.com is blocked
Google serves the same API from www.recaptcha.net:
RECAPTCHA_VERIFY_URL=https://www.recaptcha.net/recaptcha/api/siteverify RECAPTCHA_SCRIPT_URL=https://www.recaptcha.net/recaptcha/api.js
Error messages
A failed verification maps to one of these messages:
| Key | When |
|---|---|
missing |
No token was submitted. |
low_score |
The v3 score was below the threshold. |
action_mismatch |
The token was issued for a different action. |
hostname_mismatch |
The token was issued for a different host. |
expired |
The token was too old or had already been used. |
unavailable |
Google could not be reached. |
failed |
Any other failure. |
To translate or reword them, publish the language files:
php artisan vendor:publish --tag=recaptcha-lang
Testing your application
Verification is skipped in the testing environment by default, so your existing feature tests keep passing without real keys.
To control the outcome and make assertions, swap in the fake:
use Labidi\Recaptcha\Facades\Recaptcha; it('rejects bots', function () { Recaptcha::fake()->failWith('timeout-or-duplicate'); $this->post('/login', ['email' => 'a@b.test', 'password' => 'secret']) ->assertSessionHasErrors('g-recaptcha-response'); Recaptcha::assertVerified('login'); });
The fake passes every verification unless told otherwise, and it never sends a request to Google. It provides:
passWith(float $score),failWith(string ...$codes)andrespondWith(RecaptchaResponse $response)to script the outcome;assertVerified(?string $action, ?Closure $callback),assertVerifiedTimes(int $times)andassertNothingVerified()to check what was verified.
If you would rather exercise the real HTTP call, remove testing from skip_environments and use Http::fake().
Development
composer install composer test # Pest composer test:lint # Pint composer test:types # PHPStan (Larastan)
License
Apache License 2.0. See LICENSE.