How to Solve reCAPTCHA in Symfony Panther (PHP SDK)

symfony panther captcha - How to Solve reCAPTCHA in Symfony Panther (PHP SDK)

To solve a Symfony Panther CAPTCHA, read the sitekey from the page Panther has open and pass it with the current URL to the recaptcha method of the CapSkip PHP SDK. Then write the token into the hidden g-recaptcha-response textarea with executeScript and submit by clicking the form’s own button. The write is where Panther code usually breaks. reCAPTCHA keeps that textarea at display:none, so every Panther helper that would fill it, the Form object and sendKeys alike, throws ElementNotInteractableException. Pages that submit from a reCAPTCHA callback need one more step, because they ignore the field until the callback runs. This guide covers the setup on Windows, the solve, the write, both kinds of submit and the waits that keep the run reliable.

What you need

  • CapSkip running on a Windows machine. In Local mode it answers on 127.0.0.1 for that device only; in Server mode it listens on your network address or public IP, so Panther on another machine can call it over the API. Both are set under connection settings.
  • PHP 8.1 or later, with the curl and zip extensions switched on in php.ini. Panther 2.4 needs 8.1, Composer will not install php-webdriver without zip, and the Windows builds of PHP ship both extensions switched off.
  • Chrome, and a chromedriver with the same major version. The bdi tool below downloads the right one.
  • A page with a reCAPTCHA v2 widget, checkbox or invisible.
# PHP 8.1+; bdi fetches the chromedriver that matches your Chrome
composer require symfony/panther capskip/capskip
composer require --dev dbrekelmans/bdi
vendor\bin\bdi detect drivers

The last line is the Windows form of the command; on Linux or macOS it is vendor/bin/bdi. It saves chromedriver.exe in a drivers folder inside your project. Panther looks in that folder on its own, but on Windows the lookup is a trap. It finds the file, then fails to start it from the relative path. The error reads "Could not start chrome", followed by a cmd.exe message that a command is not recognized. Pass the driver’s full path to createChromeClient, as every sample here does, or put the drivers folder on PATH.

Step 1: open the page and read the sitekey

Panther starts a headless Chrome through chromedriver and loads the page. reCAPTCHA’s api.js then builds the widget and adds a textarea with the id g-recaptcha-response, so waiting for that element is the reliable sign the widget is ready. Do not wait for it to become visible: it never does, and waitForVisibility on it times out.

<?php
// composer require symfony/panther capskip/capskip
require __DIR__ . '/vendor/autoload.php';

use Symfony\Component\Panther\Client;

// The full path: on Windows, Panther finds drivers\chromedriver.exe on
// its own but then fails to start it from the relative path.
$client = Client::createChromeClient(__DIR__ . '/drivers/chromedriver.exe');
$client->request('GET', 'https://example.com/signup');

// api.js adds the hidden textarea once the widget has rendered.
$client->waitFor('#g-recaptcha-response', 20);
$crawler = $client->getCrawler();
$sitekey = $crawler->filter('[data-sitekey]')->attr('data-sitekey');

Most pages declare the widget with a div that carries a data-sitekey attribute. A page that builds it from its own script with grecaptcha.render has no such attribute, but the key is still on the page, in the address of the widget’s iframe. On those pages, use this block in place of the last line of Step 1:

// grecaptcha.render() pages have no data-sitekey; the key is the
// k= parameter of the widget's iframe.
$src = $crawler->filter('iframe[title="reCAPTCHA"]')->attr('src');
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$sitekey = $query['k'] ?? '';

Step 2: solve it with the PHP SDK

The CapSkip PHP SDK needs the sitekey and the URL of the page the widget runs on. Take the URL from Panther rather than from your own variable: after a redirect, the page Panther has open is the one the token has to be minted for.

use CapSkip\CapSkip;

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

// The page Panther has open, after any redirect, so the token is minted
// for the URL the form really lives on.
$result = $solver->recaptcha($sitekey, $client->getCurrentURL());
$token = $result['code'];   // the result holds captchaId and code

An invisible widget shows up in one of two ways: a div with a data-size attribute set to invisible, or a data-sitekey on the submit button itself. For either, pass an options array with invisible set to 1 as the third argument. A test solve of Google’s public test sitekey through a real CapSkip took about 13 seconds and returned a token of 1,678 characters.

The PHP SDK is synchronous, so the script blocks until the token comes back, and that is fine. Panther’s browser simply waits on the page, because php-webdriver’s timeouts apply to each command, not to the gaps between them. A session kept open through a 103-second solve still injected and submitted normally afterwards. The SDK polls for up to its recaptchaTimeout, 300 seconds by default, and a timeout key in the options array changes that for one call. What does run out is the token. A reCAPTCHA token is good for about two minutes after it is issued, so write and submit it straight away.

Step 3: write the token with executeScript

This is the step that decides whether a Symfony Panther CAPTCHA run works. The obvious move is to set the field through Panther’s Form object, and it fails. Panther fills fields the way a user would, by clearing them and typing into them through WebDriver, and WebDriver refuses to type into an element nobody can see. Setting the field on the Form, calling setValues, passing it to submit or calling sendKeys on the element all end in the same ElementNotInteractableException. Set the value in the page instead:

// Throws ElementNotInteractableException: the textarea is display:none.
// $form['g-recaptcha-response'] = $token;

$client->executeScript(
    'document.getElementById("g-recaptcha-response").value = arguments[0];',
    [$token]
);

executeScript passes each item of the PHP array to the script as arguments[0], arguments[1] and so on, so the token never has to be escaped into the JavaScript string. Visible fields such as an email address still take Panther’s normal form API.

Step 4: submit by clicking the real button

How you submit changes what the page sees. Build the form from its button and Panther submits it by clicking that button, which is what a visitor does:

// Built from its button, the form is sent by clicking that button, so the
// page's own submit handlers and HTML5 validation run as for a visitor.
$form = $client->getCrawler()->selectButton('Sign up')->form();
$form['email'] = 'YOUR_EMAIL';
$client->submit($form);

// Wait for something only the next page has; getCrawler() can lag.
$client->waitFor('#welcome', 15);
echo $client->getTitle(), PHP_EOL;

The other ways to submit leave things out, which matters on pages whose own script adds a field or checks the form on submit:

How the form is sentPage’s submit handlers runHTML5 validationButton name and value posted
Panther submit on a form built from its buttonYesYesYes
Panther submit on a form built from the form elementYesNoNo
form.submit() run through executeScriptNoNoNo

Validation cuts both ways. A required field left empty, or a value an email field will not accept, stops the click without any error in PHP, and nothing is posted. That is one reason the block above waits for an element that only the next page has. The other is that Panther’s crawler can still describe the old page for a moment after a submit, so code that reads getCrawler() straight away may see the form again.

Step 5: pages that submit from a callback

Some sites have no ordinary submit at all. The widget carries a data-callback attribute, or the page passes a callback to grecaptcha.render, and that function submits the form once reCAPTCHA hands it a token. Invisible widgets almost always work this way. On those pages, a token in the textarea does nothing until the callback runs, so call it yourself. Write the textarea first, because the callback usually posts the form and the server reads the token from that field. The script below does both, and on a page with several widgets it calls the first callback it finds:

// Writes the token into every reCAPTCHA textarea, then calls the widget's
// callback if it has one. ___grecaptcha_cfg is undocumented, so the walk
// looks for the object holding the sitekey instead of fixed key names.
const SUBMIT_TOKEN_JS = <<<'JS'
var token = arguments[0], found = null, seen = [];
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
  .forEach(function (t) { t.value = token; });
function walk(o, depth) {
  if (found || !o || typeof o !== 'object' || depth > 6 || seen.indexOf(o) !== -1) return;
  if (o instanceof Node || o === window) return;
  seen.push(o);
  if (Object.prototype.hasOwnProperty.call(o, 'sitekey') && o.callback) { found = o.callback; return; }
  for (var k in o) {
    if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }
  }
}
if (window.___grecaptcha_cfg) {
  Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });
}
if (typeof found === 'string') found = window[found];
if (typeof found !== 'function') return 'no callback';
found(token);
return 'callback called';
JS;

echo $client->executeScript(SUBMIT_TOKEN_JS, [$token]), PHP_EOL;

reCAPTCHA keeps each widget’s settings in a global object called ___grecaptcha_cfg. A data-callback attribute ends up there as the function’s name, and a callback passed to grecaptcha.render ends up there as the function itself, so the script handles both. The object is undocumented and its inner key names are minified, which is why the script searches for the settings rather than following a fixed path. If Google ever restructures it, the script returns no callback, and on a data-callback page you can still read the attribute and call the function by that name. Calling the callback without writing the textarea is the one mistake to avoid: the form goes out with an empty g-recaptcha-response.

Full working example

Here is the whole Symfony Panther CAPTCHA run in one script. It reads the key from either kind of page, solves, writes the token, and lets the callback submit when there is one or clicks the button when there is not.

<?php
// composer require symfony/panther capskip/capskip
// signup.php: run from the project folder with php signup.php
require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;
use Symfony\Component\Panther\Client;

const PAGE_URL = 'https://example.com/signup';

// Writes the token into every reCAPTCHA textarea, then calls the widget's
// callback if it has one. ___grecaptcha_cfg is undocumented, so the walk
// looks for the object holding the sitekey instead of fixed key names.
const SUBMIT_TOKEN_JS = <<<'JS'
var token = arguments[0], found = null, seen = [];
document.querySelectorAll('textarea[name="g-recaptcha-response"]')
  .forEach(function (t) { t.value = token; });
function walk(o, depth) {
  if (found || !o || typeof o !== 'object' || depth > 6 || seen.indexOf(o) !== -1) return;
  if (o instanceof Node || o === window) return;
  seen.push(o);
  if (Object.prototype.hasOwnProperty.call(o, 'sitekey') && o.callback) { found = o.callback; return; }
  for (var k in o) {
    if (Object.prototype.hasOwnProperty.call(o, k)) { try { walk(o[k], depth + 1); } catch (e) {} }
  }
}
if (window.___grecaptcha_cfg) {
  Object.keys(___grecaptcha_cfg.clients).forEach(function (id) { walk(___grecaptcha_cfg.clients[id], 0); });
}
if (typeof found === 'string') found = window[found];
if (typeof found !== 'function') return 'no callback';
found(token);
return 'callback called';
JS;

$solver = new CapSkip([
    'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
    'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);
// The full path: on Windows, Panther finds drivers\chromedriver.exe on
// its own but then fails to start it from the relative path.
$client = Client::createChromeClient(__DIR__ . '/drivers/chromedriver.exe');

try {
    $client->request('GET', PAGE_URL);
    // api.js adds the hidden textarea once the widget has rendered.
    $client->waitFor('#g-recaptcha-response', 20);
    $crawler = $client->getCrawler();

    $box = $crawler->filter('[data-sitekey]');
    if ($box->count() > 0) {
        $sitekey = $box->attr('data-sitekey');
    } else {
        // grecaptcha.render() pages: the key is the k= parameter of the widget iframe.
        $src = $crawler->filter('iframe[title="reCAPTCHA"]')->attr('src');
        parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
        $sitekey = $query['k'] ?? '';
    }

    // Visible fields take typing as usual.
    $form = $crawler->selectButton('Sign up')->form();
    $form['email'] = 'YOUR_EMAIL';

    $token = $solver->recaptcha($sitekey, $client->getCurrentURL())['code'];

    if ($client->executeScript(SUBMIT_TOKEN_JS, [$token]) !== 'callback called') {
        // No callback: click the real button, as a visitor would.
        $client->submit($form);
    }
    // Wait for something only the next page has; getCrawler() can lag.
    $client->waitFor('#welcome', 15);
    echo 'Submitted: ', $client->getTitle(), PHP_EOL;
} catch (ValidationException $e) {
    echo 'Not sent: ', $e->getMessage(), PHP_EOL;
} catch (CapSkipError $e) {
    echo 'Solve failed: ', get_class($e), ': ', $e->getMessage(), PHP_EOL;
} finally {
    $client->quit();
}

Change the button label, the email field and the selector you wait for to match your page. For an invisible widget, add the options array from Step 2 to the recaptcha call. On a callback page with no button at all, Steps 1, 2 and 5 are the whole job. If waitFor gives up after the click, look at the form before blaming the token: a required field left empty stops the submit with no error at all. For more on the SDK side, including the invisible and Enterprise options, the guide to solving reCAPTCHA v2 in PHP covers the recaptcha call without a browser.

Running Panther and CapSkip on different machines

Panther suites often run on a CI runner or a Linux box, while CapSkip is a Windows application. That works fine. Switch CapSkip to Server mode and it listens on your network address or public IP, so Panther on any machine can call it over the same API. Use a static public IP when the route crosses the internet, turn on API key validation, and limit the port to the addresses you expect with a Windows Firewall rule. The solver is still your own Windows machine, and solves are still unmetered.

The PHP SDK does not read environment variables by itself. Read CAPSKIP_HOST, CAPSKIP_PORT and CAPSKIP_API_KEY in your code and pass them to the constructor, as the full example does, so the same script runs at your desk and in CI. When the host is wrong or CapSkip is not running, the first call fails with a NetworkException that says it failed to connect. That takes about two seconds when nothing listens on the port, and up to 30 seconds, the SDK’s connect timeout, when a firewall drops the connection.

Common errors and what they mean

What you seeCauseFix
ElementNotInteractableException: element not interactablePanther tried to type the token into the hidden g-recaptcha-response textareaWrite the value with executeScript, as in Step 3
Could not start chrome, with a cmd.exe message that a command is not recognizedWindows: Panther found chromedriver in the drivers folder and could not start it from the relative pathPass the driver’s full path to createChromeClient, or add the drivers folder to PATH
Could not start chrome, with a message that the system cannot find the pathThere is no chromedriver.exe at the path passed to createChromeClientRun bdi detect drivers in the project folder, or correct the path
SessionNotCreatedException: This version of ChromeDriver only supports Chrome version NChrome updated itself and the driver did notRun bdi detect drivers again
RuntimeException: The port 9515 is already in use.Another Panther client or script already holds chromedriver’s default portGive each client its own port in the options array of createChromeClient
The token is written and nothing happensThe page submits from a reCAPTCHA callbackRun the Step 5 script, which writes the field and calls the callback
waitFor times out after the click and nothing was postedA required field was empty, or a value failed the field’s validationFill every required field with a valid value before you submit
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip is not running, or the host and port are wrongStart CapSkip, then check whether it should be in Local mode or Server mode
Maximum execution time exceeded during a solvePanther runs inside a web request, and on Windows that limit counts time spent waitingRun Panther from the command line, where the limit is off, or from a queue worker

FAQ

Does headless Chrome make the CAPTCHA harder to pass?

Not for this approach. The challenge is solved in CapSkip’s own browser, not in the Chrome that Panther drives, so Panther’s browser never has to pass it. Panther only carries the finished token into the form. To watch the run, set PANTHER_NO_HEADLESS to 1 in the environment before you start PHP. Panther reads it from $_SERVER, so a putenv() call inside the script has no effect.

Should my own app’s Panther tests use a solver?

Usually not. For an app you control, configure the test environment with Google’s published reCAPTCHA test keys, which always pass verification, so the tests need no solving at all. A solver earns its place when Panther drives pages you do not control, or a staging site that has to keep its real keys.

Can I run several Panther browsers at once?

Yes, one per process or several in one, as long as each client gets its own chromedriver port. Every client starts its own chromedriver on port 9515 unless you pass a different port in the options of createChromeClient, and the second one on the same port fails on its first request. The PHP SDK is synchronous, so each process solves one CAPTCHA at a time, and CapSkip itself runs 10 reCAPTCHA solves at once by default.

Does the same approach work for Cloudflare Turnstile?

The shape is the same. Turnstile puts its token in a hidden input named cf-turnstile-response. Panther cannot fill it either: the Form object reports an invalid element state, sendKeys reports that the element is not interactable, and executeScript sets it. Solve it with the SDK’s turnstile method instead of recaptcha, and read the sitekey from the data-sitekey attribute of the cf-turnstile div. The Step 5 script handles reCAPTCHA callbacks only.

The short version

For a Symfony Panther CAPTCHA, pass the driver’s full path to createChromeClient on Windows, load the page and wait for the g-recaptcha-response textarea, then read the sitekey from data-sitekey or the widget iframe. Solve with the PHP SDK using Panther’s current URL. Write the token with executeScript, never with the Form object, then click the real button, or call the callback on pages that have one. Wait for the next page before reading it, and switch CapSkip to Server mode when Panther runs elsewhere.

A Panther scraper that meets a widget on every page pays nothing extra when the captcha bypass runs on your own Windows machine. There is no reason to skip a page because a challenge sits on it.