Open source July 2026
Laracaptcha

Laracaptcha

Laracaptcha

Laracaptcha puts Cloudflare Turnstile, reCAPTCHA v2 and reCAPTCHA v3 behind one widget, one validation rule and one facade. Your code never names the provider: CAPTCHA_DRIVER in .env does, so moving from Google to Cloudflare is a change of keys and nothing else. Since 1.1 it also works inside Livewire components, where a captcha usually breaks.

Installation

composer require edulazaro/laracaptcha
CAPTCHA_DRIVER=turnstile      # or recaptcha_v2, recaptcha_v3
TURNSTILE_KEY=0x4AAA...
TURNSTILE_SECRET=0x4AAA...

reCAPTCHA reads RECAPTCHA_KEY and RECAPTCHA_SECRET (v3 can have its own RECAPTCHA_V3_KEY and RECAPTCHA_V3_SECRET, plus RECAPTCHA_V3_MIN_SCORE, 0.5 by default). Publishing the config is optional: php artisan vendor:publish --tag=laracaptcha-config.

For local work, Cloudflare's test keys always pass: site key 1x00000000000000000000AA, secret 1x0000000000000000000000000000000AA.

The two halves

Every captcha has a browser half and a server half, and the package gives you one piece for each:

Piece What it does
Browser <x-laracaptcha::widget /> Draws the challenge and loads the provider's script once per page
Server Captcha rule, or Captcha::verify() Sends the token to the provider and gives you a pass or a fail

The widget is drawn by a small script of the package rather than by the provider's own scan of the page on load. That scan only sees what exists when the page loads; the package's script also draws a widget that arrives later, after wire:navigate or when a Livewire update reveals a form step.

A plain Blade form

<form method="POST" action="/contact">
    @csrf
    <x-laracaptcha::widget theme="dark" />
    <button>Send</button>
</form>
use EduLazaro\Laracaptcha\Facades\Captcha;
use EduLazaro\Laracaptcha\Rules\Captcha as CaptchaRule;

$request->validate([
    Captcha::responseField() => ['required', new CaptchaRule],
]);

responseField() is the name the provider gives its hidden input (cf-turnstile-response, g-recaptcha-response), asked of the driver so the controller does not change with the provider. reCAPTCHA v3 has nothing to click: the widget is a hidden input that fetches a scored token when the form is submitted.

Widget props: theme (auto, light, dark), size, action, driver to use one other than the default, and for Turnstile language.

Inside Livewire

Livewire sends the component's properties to the server, not the inputs of the form, so the token the provider writes into its hidden field never arrives. Bind the widget to a property and let the trait do the checking:

<form wire:submit="send">
    <x-laracaptcha::widget wire:model="captcha" />
    @error('captcha') <span>{{ $message }}</span> @enderror
    <button>Send</button>
</form>
use EduLazaro\Laracaptcha\Livewire\WithCaptcha;

class ContactForm extends Component
{
    use WithCaptcha;

    public function send(): void
    {
        $this->verifyCaptcha();

        // the person passed
    }
}

What the trait handles for you:

  • The property. $captcha is declared by the trait; the widget fills it when the challenge is solved and empties it when the token expires.
  • The reset. A provider accepts a token once, so verifyCaptcha() empties the property and redraws the challenge after every check, passed or failed. Without it, a second attempt after a typo fails whatever the person does.
  • The error. A failure is a validation error on captcha, so @error('captcha') and assertHasErrors('captcha') work as for any field.

Once per session, and with no button

A sign-in someone may restart several times should not ask every time. verifyCaptchaOnce() remembers a pass in the session under a name of your choosing:

$this->verifyCaptchaOnce('login');
@unless ($this->captchaPassed('login'))
    <x-laracaptcha::widget wire:model.live="captcha" />
@endunless

With wire:model.live the token reaches the component the moment the challenge is solved, so an updatedCaptcha() hook can carry on by itself. In Turnstile's managed mode that is usually without the person clicking anything:

public function updatedCaptcha(): void
{
    $this->verifyCaptchaOnce('login');
    $this->sendLoginCode();
}

Making a stolen token worth less

Three checks, the first on by default:

  • Single use. A token that has passed is remembered for reuse_ttl minutes (5) and refused if it comes back. Turn it off with CAPTCHA_PREVENT_REUSE=false only if the provider already does it.

  • Action. Turnstile and reCAPTCHA v3 sign the action the widget was drawn with into the token. Give the widget and the check the same name and a token solved on your newsletter form cannot open your sign-in:

    <x-laracaptcha::widget wire:model="captcha" action="login" />
    
    $this->verifyCaptcha(action: 'login');
    // or: CaptchaRule::make(action: 'login')
    

    The refusal comes back as action-mismatch. reCAPTCHA v2 has no action and ignores it.

  • Hostname. Every provider reports where the challenge was solved. List your hosts and a token solved on another site that uses your key is refused with hostname-mismatch:

    CAPTCHA_HOSTNAMES=example.com,www.example.com
    

    It is empty by default, because Cloudflare's test keys report example.com wherever they run.

When the provider cannot be reached, or answers with something that is not its JSON, the check fails (unreachable). An outage stops submissions; it never lets them through.

The result

Captcha::verify() hands back the verdict for code that does not go through validation:

$result = Captcha::verify($token, $request->ip());

$result->passed();     // or ->failed()
$result->score;        // reCAPTCHA v3 only
$result->errorCodes;   // the provider's, plus low-score, action-mismatch, hostname-mismatch, unreachable
$result->raw;          // the provider's whole answer

In tests

Captcha::fake() replaces every driver with one that answers without the network and records what it was asked. The widget renders nothing while faked, so pages keep loading in tests.

use EduLazaro\Laracaptcha\Facades\Captcha;

$fake = Captcha::fake();                // everything passes
Captcha::fake(success: false);          // everything fails
Captcha::fake(score: 0.3);              // with a v3 score

Livewire::test(ContactForm::class)
    ->set('captcha', 'any-token')
    ->call('send')
    ->assertHasNoErrors();

$fake->attempts();                      // [['token' => 'any-token', 'ip' => ...]]

From 1.0 to 1.1

The widget is a class component now, so run php artisan view:clear after updating, or views compiled against 1.0 keep drawing the old one. A rule given an action now checks it on Turnstile too, which only matters if you were passing one that the widget did not carry.

built and maintained by Edu Lazaro · MIT license