google/recaptcha
google/recaptcha is the official PHP client for server-side verification of reCAPTCHA v2 and v3 responses, helping protect forms and APIs from spam and abuse. It supports Composer installation and requires PHP 8+.
Requirements & Installation — google/recaptcha:^2.1 requires PHP >= 8.4. That's the biggest change from the 1.5 line: this is a real upgrade blocker for apps on PHP 8.2/8.3. Plan the language upgrade before pulling the package.
composer require google/recaptcha:^2.1
No package service provider is shipped — this is a framework-agnostic library. You wire it into Laravel yourself.
Configure Keys (config/services.php + .env)
RECAPTCHA_SITE_KEY=your_site_key
RECAPTCHA_SECRET=your_secret_key
RECAPTCHA_SCORE_THRESHOLD=0.5
RECAPTCHA_ACTION=submit
Bind a Configured Instance in AppServiceProvider::register():
use ReCaptcha\ReCaptcha;
use ReCaptcha\RequestMethod\CurlPost;
$this->app->singleton(ReCaptcha::class, function () {
return (new ReCaptcha(config('services.recaptcha.secret'), new CurlPost(10)))
->withScoreThreshold((float) config('services.recaptcha.score_threshold', 0.5))
->withExpectedAction(config('services.recaptcha.action', 'submit'));
});
The new immutable with*() methods make this safe as a singleton — including under Laravel Octane, RoadRunner, and long-lived queue workers.
First Verification
use ReCaptcha\ReCaptcha;
use ReCaptcha\Response;
/** @var Response $response */
$response = app(ReCaptcha::class)->verify(
$request->input('g-recaptcha-response'),
$request->ip()
);
if (! $response->isSuccess()) {
// $response->getErrorCodes() returns ReCaptcha::E_* constants
}
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script>
grecaptcha.ready(() => {
grecaptcha.execute('{{ config('services.recaptcha.site_key') }}', { action: 'submit' })
.then(token => document.querySelector('#g-recaptcha-response').value = token);
});
</script>
The package has no built-in validation rule, so wrap verification in one:
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use ReCaptcha\ReCaptcha;
class Recaptcha implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$response = app(ReCaptcha::class)->verify((string) $value, request()->ip());
if (! $response->isSuccess()) {
$fail('Captcha verification failed: '.implode(', ', $response->getErrorCodes()));
}
}
}
Then in a FormRequest:
public function rules(): array
{
return [
'g-recaptcha-response' => ['required', 'string', new \App\Rules\Recaptcha()],
];
}
Pin the expected action and hostname so tokens minted elsewhere are rejected:
$recaptcha = app(ReCaptcha::class)
->withExpectedAction('login')
->withExpectedHostname('app.example.com');
$response = $recaptcha->verify($token, $request->ip());
with*() returns a clone, so the shared singleton keeps its base configuration intact between requests.
ReCaptcha\Response now implements \JsonSerializable:
return response()->json([
'ok' => $response->isSuccess(),
'score' => $response->getScore(),
'failures' => $response->getErrorCodes(),
]);
// or simply, since it is JsonSerializable:
return response()->json($response);
Useful for SPA/mobile responses and for structured request logging.
All transports accept an int $timeout = 60 parameter and now share HTTP‑200‑only validation:
use ReCaptcha\ReCaptcha;
use ReCaptcha\RequestMethod\CurlPost; // keep-alive + TLS session reuse
use ReCaptcha\RequestMethod\Post; // stream-based
use ReCaptcha\RequestMethod\SocketPost; // raw socket fallback
$recaptcha = new ReCaptcha($secret, new CurlPost(timeout: 5));
Pass a short timeout for user-facing requests; use a larger one from background jobs.
For environments where www.google.com is blocked:
use ReCaptcha\ReCaptcha;
use ReCaptcha\RequestMethod\CurlPost;
$recaptcha = new ReCaptcha(
$secret,
new CurlPost(10, ReCaptcha::SITE_VERIFY_URL_ALTERNATIVE),
);
// resolves to: https://www.recaptcha.net/recaptcha/api/siteverify
Because with*() clones, you can safely reuse one ReCaptcha instance in persistent workers:
$perRequest = app(ReCaptcha::class)->withExpectedAction($action);
Avoid the old set*() mutators on the singleton — they mutate shared instance state.
ReCaptcha\, not Google\Recaptcha\. Classes: ReCaptcha\ReCaptcha, ReCaptcha\Response, ReCaptcha\RequestMethod\*.set*() vs with*()withExpectedHostname, withExpectedApkPackageName, withExpectedAction, withScoreThreshold, withChallengeTimeout — each returns a new ReCaptcha.set*() methods are still present but mutate in place. Keep them out of singletons, Octane, and long-lived workers.score (null → ReCaptcha::E_SCORE_THRESHOLD_NOT_MET).challenge_ts is empty or unparseable (ReCaptcha::E_CHALLENGE_TIMEOUT).error-codes is filtered so only string elements are retained — safe to implode()/log, but don't assume Google's raw array shape.CurlPost, Post, SocketPost) consistently surface as ReCaptcha::E_BAD_RESPONSE. Previously stream/socket transports could silently differ under proxies or error pages. Inspect getErrorCodes() rather than trying to read HTTP status directly.#[\SensitiveParameter] is now applied to $secret in ReCaptcha::__construct() and RequestParameters::__construct(). Stack traces redact it in 2.1.0 — older versions do not. Still avoid logging secrets in query strings regardless of version.
CurlPost now lazily creates and reuses its CurlHandle across submit() calls, so a singleton ReCaptcha gets HTTP keep-alive and TLS session resumption for free.CurlPost instance across PHP processes; per-worker singletons are fine. If you need per-call isolation, construct a fresh CurlPost..env.testing:
RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe
RECAPTCHA_SECRET=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
$this->mock(ReCaptcha::class, fn ($m) =>
$m->shouldReceive('verify')->andReturn(new \ReCaptcha\Response(true))
);
Response is still directly constructible (new Response(true)), convenient for fixtures.Response is JsonSerializable, you can assert against json_encode($response) in feature tests.$request->ip(). Behind proxies (Cloudflare, ALB) configure TrustProxies; otherwise Google scores against the proxy IP and you'll see mysterious failures.g-recaptcha-response or the Response. Re-submitting yields timeout-or-duplicate. Don't stash the token in a queued job payload.withExpectedAction() is case-sensitive and must match the frontend action exactly.withExpectedHostname() is the only way to catch domain misconfiguration early.new CurlPost(5) (or similar) on user-facing paths.ReCaptcha\RequestMethod (submit(RequestParameters $params): string). Useful if you already tune a Guzzle/Symfony HTTP client, or for test doubles.new CurlPost($timeout, ReCaptcha::SITE_VERIFY_URL_ALTERNATIVE) for geographies where google.com is unreachable.Response: subclassing ReCaptcha\Response is possible — if you do, verify JsonSerializable output still matches what API consumers expect.config('services.recaptcha.secret') per environment.ReCaptcha registered as a singleton; use with*() for per-request tweaks, not set*().withExpectedAction() / withExpectedHostname() for v3.getErrorCodes() + IP.#[\SensitiveParameter] only on 2.1.0+.How can I help you explore Laravel packages today?