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.