Reader Stacks

Adding CAPTCHA to a Laravel Form: Custom and Google reCAPTCHA

reCAPTCHA v3 never shows a visible challenge at all — it scores the request invisibly, which trades a smoother user experience for having no direct 'prove you're human' moment for a legitimate user to interact with.

Adding CAPTCHA to a Laravel Form: Custom and Google reCAPTCHA

Protecting a public form from spam submissions can go through a self-hosted custom CAPTCHA image or Google's reCAPTCHA service — each with genuinely different trade-offs worth understanding before choosing one.

A custom CAPTCHA using a session-stored code

Route::get('/captcha-image', function () {
    $code = Str::random(6);
    session(['captcha_code' => $code]);

    $image = imagecreatetruecolor(150, 50);
    $bg = imagecolorallocate($image, 255, 255, 255);
    $textColor = imagecolorallocate($image, 0, 0, 0);
    imagefill($image, 0, 0, $bg);
    imagestring($image, 5, 30, 15, $code, $textColor);

    header('Content-Type: image/png');
    imagepng($image);
    imagedestroy($image);
});


Refresh

Appending Date.now() as a cache-busting query parameter on refresh is necessary since browsers otherwise cache the image URL and would keep showing the same already-solved (or already-seen) CAPTCHA code instead of generating a genuinely new one.

Validating the custom CAPTCHA on submission

public function store(Request $request)
{
    if (strtoupper($request->captcha) !== session('captcha_code')) {
        return back()->withErrors(['captcha' => 'Incorrect CAPTCHA code.']);
    }

    session()->forget('captcha_code');
    // proceed with form processing
}

Clearing the session value after a successful check prevents the same code from being reused across multiple submissions — a self-hosted CAPTCHA like this also completely avoids depending on any third-party service being reachable at all.

Google reCAPTCHA v2 ("I'm not a robot" checkbox)


public function store(Request $request)
{
    $request->validate(['g-recaptcha-response' => 'required']);

    $response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
        'secret' => config('services.recaptcha.secret_key'),
        'response' => $request->input('g-recaptcha-response'),
    ]);

    if (! $response->json('success')) {
        return back()->withErrors(['captcha' => 'CAPTCHA verification failed.']);
    }
}

The verification happens server-side via a second HTTP call to Google's own API — the client-side widget alone only produces a token; that token still needs this separate server-side check to actually confirm it's valid, since a client-controlled value can never be trusted without independent server-side verification.

Google reCAPTCHA v3 (invisible, score-based)

const token = await grecaptcha.execute('your-site-key', { action: 'submit' });
document.getElementById('recaptcha-token').value = token;
$response = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [
    'secret' => config('services.recaptcha.secret_key'),
    'response' => $request->input('recaptcha_token'),
]);

$score = $response->json('score'); // 0.0 (likely bot) to 1.0 (likely human)

if ($score < 0.5) {
    return back()->withErrors(['captcha' => 'Verification failed.']);
}

v3 never shows a visible challenge at all — instead, it returns a 0.0–1.0 confidence score based on background behavioral analysis, and the application itself decides the score threshold below which a submission is rejected; this trades a smoother, frictionless user experience for having no explicit "prove you're human" moment at all.

Choosing between the three approaches

A custom CAPTCHA avoids any third-party dependency but is generally easier for modern bots to defeat via OCR; reCAPTCHA v2 is well-tested and widely recognized by users but adds a visible interruption to the form; reCAPTCHA v3 is invisible and frictionless but requires tuning a score threshold and offers less certainty for any individual submission.