Implement a ReCaptcha React component validated by Symfony

We are going to implement a React component to render a Google ReCaptcha element, and will add PHP / Symfony service to check that the ReCaptcha was correctly filled by the user.

In this article, we are going to implement a React component to render a Google ReCaptcha V2 element and a PHP / Symfony service to check that the ReCaptcha was correctly filled by the user. There are a few subtleties concerning ReCaptcha's integration into a React component, which is why I wrote this article.

Create the ReCaptcha component

Insert first Google's script in your main HTML file:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- ... -->
    <script src="https://www.google.com/recaptcha/api.js"></script>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

Create a dedicated component for rendering a ReCaptcha element:

import React, { Component } from 'react'
import PropTypes from 'prop-types'

class ReCaptcha extends Component {
  constructor(props) {
    super(props)

    this.loadRecaptcha = this.loadRecaptcha.bind(this)
    this.handleChange = this.handleChange.bind(this)
  }

  componentDidMount() {
    if (document.readyState === 'complete') {
      // Window was already loaded (the component is rendered later on)
      // ReCaptacha can be safely loaded
      this.loadRecaptcha()
    } else {
      // Wait that the window gets loaded to load the ReCaptcha
      window.onload = this.loadRecaptcha
    }
  }

  getValue() {
    window.grecaptcha.getResponse(this.recatchaElt)
  }

  loadRecaptcha() {
    const { id, apiKey, theme } = this.props

    this.recatchaElt = window.grecaptcha.render(id, {
      sitekey: apiKey,
      theme,
      callback: this.handleChange,
    })
  }

  handleChange() {
    const { onChange } = this.props

    onChange(this.getValue())
  }

  render() {
    const { id } = this.props

    return <div id={id} />
  }
}

ReCaptcha.propTypes = {
  id: PropTypes.string.isRequired,
  apiKey: PropTypes.string.isRequired,
  onChange: PropTypes.func.isRequired,
  theme: PropTypes.oneOf(['dark', 'light']),
}

ReCaptcha.defaultProps = {
  theme: 'light',
}

export default ReCaptcha

As you can see, I defined 3 properties for the component:

  • id (required): ID of the ReCaptcha element.
  • apiKey (required): the API key that needs to get generated on Google's website with your account.
  • onChange (required): a method that will handle ReCaptcha's status change in the parent component.
  • theme (default: light): ReCaptcha's theme (dark or light).

💡 Tip: If you are using ESLint to check code quality, you must add some configuration to avoid warnings concerning the use of the document and window global variables. This is my configuration in my .eslintrc.js file at the root of my project:

module.exports = {
  extends: 'airbnb',
  rules: {
    'react/jsx-filename-extension': 'off',
  },
  env: {
    // 👇This line avoids warnings for browser global variables
    browser: true,
  },
}

You can then make use of the component in your parent component:

import React, { Component } from 'react'
import ReCaptcha from './ReCaptcha'

class App extends Component {
  constructor(props) {
    super(props)

    this.state = {
      captchaResponse: null,
    }
  }

  render() {
    return (
      <div>
        <ReCaptcha
          id="recaptcha"
          apiKey="YOUR_API_KEY"
          onChange={(response) => {
            this.setState({ captchaResponse: response })
          }}
        />
      </div>
    )
  }
}

export default App

Backend check using PHP / Symfony

Let's create a service that will check the ReCaptcha value (captchaResponse in the state of the example component making use of the ReCaptcha component above).

This service is designed for Symfony, but the cool thing is that Symfony's services are pure PHP objects and are therefore framework agnostic, so these classes may be used in any PHP project.

Captcha checker interface

Let's define first a Captcha checker interface, so that the switch to any other Captcha system in the future could be easily made:

<?php

namespace App\Captcha;

use GuzzleHttp\Client;

interface CaptchaCheckerInterface
{
    /**
     * Checks captcha response
     *
     * @param string $captchaResponse
     * @return bool
     */
    public function check(string $captchaResponse): bool;
}

Captcha checker for ReCaptcha

Install Guzzle first:

composer require guzzlehttp/guzzle:~6.0

Define the ReCaptcha checker:

<?php

namespace App\Captcha;

use GuzzleHttp\Client;

class GoogleReCaptchaChecker implements CaptchaCheckerInterface
{
    protected $secret;

    public function __construct(string $secret)
    {
        $this->secret = $secret;
    }

    /**
     * {@inheritDoc}
     */
    public function check(string $captchaValue): bool
    {
        $response = $this->getCaptchaResponse($captchaValue);

        // Better checks could be done here
        if ($data && isset($data['success']) && true === $data['success']) {
            return true;
        }

        return false;
    }

    private function getCaptchaResponse($captchaValue): array
    {
        $response = $this->getClient()->request(
            'POST',
            'recaptcha/api/siteverify',
            [
                'form_params' => [
                    'secret'   => $this->secret,
                    'response' => $captchaValue,
                ],
            ]
        );

        return json_decode($response->getBody(), true);
    }

    private function getClient(): Client
    {
        return new Client([
            'base_uri' => 'https://www.google.com',
        ]);
    }
}

If you're using Symfony, define your ReCaptcha secret provided by Google as an environment variable and autoconfigure the corresponding service:

# config/services.yaml
services:
  App\Service\CaptchaChecker:
    arguments:
      $secret: '%env(GOOGLE_RECAPTCHA_SECRET)%'

Make use of the service to check Captcha response value

You can now make use of the service anywhere, like in a controller:

<?php

namespace App\Controller;

use App\Captcha\CaptchaCheckerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class CaptchaController extends Controller
{
    /**
     * @Route("/captcha/check")
     */
    public function check(Request $request, CaptchaCheckerInterface $captchaChecker)
    {
        $isCaptchaValid = $captchaChecker->check($request->request->get('captcha'));

        // ...
    }
}