Skip to content

Laravel's validation pipeline makes CAPTCHA almost invisible once it's set up — your controller just asks for a captcha field on the Form Request, and the rule does the upstream verify call. The setup is about thirty minutes if you've never done it, ten if you have. This post walks the full thing with CaptchaLa: a service class, a custom validation rule, a Blade snippet, and a service provider binding.

Tested on Laravel 10 and 11. Lumen notes at the bottom.

Quick start (2 minutes)

bash
composer require guzzlehttp/guzzle    # if you don't already have it

Then four files: a service, a rule, a Blade partial, and the service provider binding. No package needed — the captcha.la verify endpoint is one HTTP call and Laravel's HTTP client handles it cleanly.

The service

php
<?php
// app/Services/CaptchalaService.php
namespace App\Services;

use Illuminate\Http\Client\Factory as HttpFactory;

class CaptchalaService
{
    public function __construct(private HttpFactory $http) {}

    public function verify(string $token, string $action, ?string $clientIp = null): array
    {
        $resp = $this->http
            ->withHeaders([
                'X-App-Key'    => config('services.captchala.app_key'),
                'X-App-Secret' => config('services.captchala.app_secret'),
            ])
            ->timeout(5)
            ->post('https://apiv1.captcha.la/v1/challenge/verify', [
                'token'     => $token,
                'action'    => $action,
                'client_ip' => $clientIp,
            ]);

        if ($resp->failed()) {
            return ['ok' => false, 'reason' => 'upstream_error', 'risk_score' => null];
        }

        $body = $resp->json();
        $data = $body['data'] ?? [];

        $ok = ($body['code'] ?? -1) === 0
            && ($data['valid'] ?? false) === true
            && ($data['action'] ?? '') === $action;

        return [
            'ok'         => $ok,
            'reason'     => $ok ? null : 'rejected',
            'risk_score' => $data['risk_score'] ?? null,
            'offline'    => $data['offline'] ?? false,
        ];
    }
}

Add to config/services.php:

php
'captchala' => [
    'app_key'    => env('CAPTCHALA_APP_KEY'),
    'app_secret' => env('CAPTCHALA_APP_SECRET'),
],

And the env vars in .env. The app_key is also exposed to the front end (next file), so we'll re-emit it as a public config too.

The validation rule

php
<?php
// app/Rules/CaptchalaToken.php
namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use App\Services\CaptchalaService;

class CaptchalaToken implements ValidationRule
{
    public function __construct(private string $action) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (!is_string($value) || $value === '') {
            $fail('Captcha verification missing.');
            return;
        }

        $ip = request()->ip(); // Laravel resolves X-Forwarded-For when trusted proxies are configured
        $result = app(CaptchalaService::class)->verify($value, $this->action, $ip);

        if (!$result['ok']) {
            $fail('Captcha verification failed.');
        }
    }
}

Use it in a Form Request like any other rule:

php
// app/Http/Requests/SignupRequest.php
class SignupRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email'         => ['required', 'email', 'unique:users,email'],
            'password'      => ['required', 'min:8'],
            'captcha_token' => ['required', new \App\Rules\CaptchalaToken('signup')],
        ];
    }
}

The controller stays trivial:

php
public function store(SignupRequest $request)
{
    $user = User::create($request->only(['email', 'password']));
    return response()->json(['ok' => true, 'id' => $user->id]);
}

Validation runs the captcha verify before the controller is even called. If the rule fails, Laravel returns 422 automatically.

The Blade partial

blade
{{-- resources/views/partials/captcha.blade.php --}}
<script src="https://cdn.captcha-cdn.net/captchala.js"></script>
<input type="hidden" name="captcha_token" id="captcha_token" form="{{ $form }}">

<script>
  const widget = window.Captchala.init({
    appKey: '{{ config('services.captchala.app_key') }}',
    product: 'bind',
    bindTo: '#{{ $submitId }}',
    action: '{{ $action }}',
    lang: '{{ app()->getLocale() }}',
  });
  widget
    .onSuccess((res) => {
      document.getElementById('captcha_token').value = res.token;
      document.getElementById('{{ $form }}').submit();
    })
    .bindTo('#{{ $submitId }}');
</script>

Drop into any form:

blade
<form id="signup-form" action="{{ route('signup') }}" method="POST">
    @csrf
    <input name="email" type="email" required>
    <input name="password" type="password" required>
    <button type="submit" id="signup-submit">Sign up</button>
    @include('partials.captcha', ['form' => 'signup-form', 'submitId' => 'signup-submit', 'action' => 'signup'])
</form>

@csrf sits alongside the captcha — they don't conflict. CSRF protects against cross-site submission; CAPTCHA defends against automation. Different threat models, both still needed.

Service Provider (optional)

Bind as singleton in a provider if you want one instance reused:

php
// app/Providers/CaptchalaServiceProvider.php
public function register(): void
{
    $this->app->singleton(\App\Services\CaptchalaService::class);
}

Register in bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10).

Common pitfalls

  1. CSRF token vs captcha token confusion. They are separate. CSRF lives in the _token field and protects against cross-origin requests. Captcha lives in captcha_token and protects against automation. Both belong on the form. Removing CSRF because "we have captcha now" is a mistake.
  2. Trusted proxies not configured. If you're behind Nginx or an AWS ALB, request()->ip() returns the proxy address unless you set App\Http\Middleware\TrustProxies::$proxies = '*' (or the specific subnet). Without that, the IP forwarded upstream is wrong and the risk score suffers.
  3. Form submitting before the token arrives. The Blade snippet above intercepts the submit via bindTo so the form only submits after onSuccess. If you have your own jQuery submit handler, make sure it doesn't race the widget.
  4. Lumen compatibility. Lumen doesn't ship the validation factory the same way. The service class works unchanged, but you'll need to wire validation manually in the controller — Validator::make($request->all(), [...]) and call verify() directly. Form Request rules don't carry over cleanly.
  5. Token reuse on validation failure. If one of the other fields fails validation (email taken, password too weak), the captcha token is still consumed. The Form Request returns 422 with all errors, and the user sees an empty captcha widget on the rerendered page — they need to re-verify. Make sure your Blade partial is included on the error response, not just the initial GET.

Where to go next

  • Full Laravel example repo and middleware-based variant: captcha.la/docs
  • Free tier and per-unit pricing: captcha.la/pricing
  • If you're protecting a high-value flow (payment, password change), CaptchaLa supports server-side token issuance with TTL and IP binding — your controller can pre-authorize a token before the form even renders, which closes the replay window further

Once the rule is wired in, adding captcha to a new form is a one-line change to the Form Request plus the Blade partial. That's the Laravel way, and the CaptchaLa endpoint fits cleanly into it without a custom package.

Articles are CC BY 4.0 — feel free to quote with attribution