Search by

labidi / recaptcha

labidi

Google reCAPTCHA (v2 checkbox, v2 invisible and v3) integration for Laravel, with Blade and Inertia.js support.

Package info

github.com/labidi/recaptcha

pkg:composer/labidi/recaptcha

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-09-24 19:09 UTC

This package is auto-updated.

Last update: 2026-09-24 19:15:02 UTC


README

tests

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 422 response.

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.js once, even across Inertia page visits;
  • reuse the script if @recaptchaScript already loaded it in app.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) and respondWith(RecaptchaResponse $response) to script the outcome;
  • assertVerified(?string $action, ?Closure $callback), assertVerifiedTimes(int $times) and assertNothingVerified() 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.