How to Solve CAPTCHAs in Symfony Messenger Workers (PHP)

To solve CAPTCHAs in Symfony Messenger, dispatch a small message that holds the page address and solve inside its handler. Keep the queue in one SQLite file, so a Windows worker needs no Redis or RabbitMQ. This symfony messenger captcha setup has four traps, and this guide covers each one. The exception that looks made for retrying ignores max_retries. Messenger has no time limit for a single message. A second worker takes the message over and solves again if the redelivery timeout is shorter than the slowest solve. And without the pcntl extension, which Windows PHP does not have, Ctrl+C kills the worker in the middle of a solve.
What you need
- CapSkip running on a Windows PC, with the reCAPTCHA solver switched on.
- PHP 8.4.1 or later and Composer. Symfony 8.1 requires that PHP version, and the handler below uses PHP 8.4’s HTML parser. Everything was run on Windows 11 with PHP 8.4.15, Symfony 8.1.8, doctrine/dbal 4.5.0 and capskip/capskip 1.3.0.
- Three PHP extensions switched on in php.ini: curl, pdo_sqlite and openssl. The worker ran with only the first two against a plain http test page, but fetching an https page needs openssl, and so does Composer when it downloads packages.
- No Redis, RabbitMQ or database server. The Doctrine transport keeps the queue in a SQLite file.
- An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a worker on another machine can call it over the API. Both are set under connection settings.
The measurements below come from a mock CapSkip and a mock login page on the same PC, used with the real PHP SDK and the real Symfony components. The times that belong to the SDK and to Messenger, such as 120 seconds, hold for your own site too. The accept and reject rules of the mock site are the mock’s own.
Step 1: create the project
Check php -v before anything else, because every command in this guide uses the PHP on your PATH. With an older PHP first (XAMPP ships 8.0, for example), composer create-project does not stop. It prints a note that the newest skeleton needs PHP 8.4 and installs an old one anyway: on PHP 8.0.30 that was Symfony 6.0. Put the PHP 8.4 folder first on the PATH of this window before you start.
# PowerShell, with PHP 8.4 first on the PATH composer create-project symfony/skeleton cs-worker cd cs-worker composer require symfony/messenger symfony/doctrine-messenger doctrine/doctrine-bundle symfony/http-client capskip/capskip --no-scripts Remove-Item config\packages\doctrine.yaml, config\packages\messenger.yaml
Composer may ask whether to include Docker configuration from recipes. Answer n, because the default adds a compose.yaml for a PostgreSQL service that this setup does not use. Two other details matter. The Doctrine recipe writes a doctrine.yaml that asks for the doctrine/orm package, which this project does not use, so cache:clear fails with an error saying that doctrine/orm is required. Composer’s no-scripts flag skips that step, and the last line deletes the two YAML files the recipes wrote. Step 2 replaces them with PHP files, which Symfony loads from config/packages just the same.
Step 2: point the transport at a SQLite file
Replace the DATABASE_URL and MESSENGER_TRANSPORT_DSN lines the recipe added to .env with these, and add the rest. Put real values such as the password in .env.local, which the Symfony skeleton keeps out of Git.
MESSENGER_TRANSPORT_DSN=doctrine://default DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" CAPSKIP_HOST=127.0.0.1 CAPSKIP_PORT=8080 CAPSKIP_API_KEY=YOUR_API_KEY SITE_PASSWORD=YOUR_PASSWORD
The SQLite path starts from %kernel.project_dir% on purpose. A relative path such as sqlite:///var/data.db failed with unable to open database file when the command ran from C:\. Run from a folder that had its own var directory, it quietly created a second database there, so two commands saw two queues. The handler reads the CapSkip variables through Autowire, because the SDK does not read them on its own. CAPSKIP_API_KEY only matters if you switch on API key validation in CapSkip.
<?php
// config/packages/doctrine.php (replaces the recipe's doctrine.yaml, which asks for doctrine/orm)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
'doctrine' => [
'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'],
],
]);The transport settings go in the second file.
<?php
// config/packages/messenger.php (replaces the recipe's messenger.yaml)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
'framework' => [
'messenger' => [
'failure_transport' => 'failed',
'transports' => [
'async' => [
'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%',
// Longer than the slowest handler, or a second worker takes the message over.
'options' => ['redeliver_timeout' => 900],
'retry_strategy' => ['max_retries' => 3, 'delay' => 30000, 'multiplier' => 2, 'max_delay' => 300000],
],
'failed' => 'doctrine://default?queue_name=failed',
],
'routing' => ['App\Message\SolveLogin' => 'async'],
],
],
]);Three settings carry the weight. The retry strategy waits about 30, 60 and 120 seconds between attempts, which gives CapSkip time to come back. Messenger varies each delay by up to 10 percent either way, and one test logged 29.4, 57.0 and 117.9 seconds. The defaults retry three times, starting one second apart. The failed transport matters more than it looks: without one, a message that has used its last retry is deleted and lost. And redeliver_timeout is explained in its own section below.
Step 3: write the message
The message carries the inputs for the login and nothing the solver produces. A reCAPTCHA token is valid for two minutes and works once, according to Google’s documentation. The mock site enforces that rule. A message that carried a token was accepted when it ran at once and rejected when it ran 130 seconds later. A message that carried only the address solved again and was accepted. So the handler fetches the page, solves and submits back to back.
<?php
// src/Message/SolveLogin.php
namespace App\Message;
final class SolveLogin
{
public readonly string $jobId;
public function __construct(
public readonly string $siteUrl,
public readonly string $username,
) {
// Stays the same across retries: the message is serialized once and re-sent as it is.
$this->jobId = bin2hex(random_bytes(8));
}
}The job id is created once, when the message is built, and stays the same across retries because Messenger sends the same serialized message again. The ledger near the end of this guide relies on it. Messenger stores the message with PHP serialize, so renaming this class while messages are queued breaks those rows.
Step 4: decide what to retry
CapSkip raises four exception types, and they do not all deserve another attempt. A ValidationException means the call itself is wrong and will fail the same way next time. An ApiException is worth retrying only for ERROR_CAPTCHA_UNSOLVABLE, which is how CapSkip says that this solve did not work. A NetworkException (CapSkip not running) and a TimeoutException are worth retrying. If you changed the Error Replacement text in CapSkip, the match on ERROR_CAPTCHA_UNSOLVABLE cannot see it, and those failures are not retried.
<?php
// src/CapSkipFailure.php
namespace App;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
final class CapSkipFailure
{
public static function toMessenger(CapSkipError $e): \Throwable
{
// A bad parameter or an API error other than "unsolvable" will fail the same way next time.
$retry = !$e instanceof ValidationException
&& (!$e instanceof ApiException || str_contains($e->getMessage(), 'ERROR_CAPTCHA_UNSOLVABLE'));
// A plain exception is retried by the retry strategy, which also decides the delay and the limit.
return $retry
? new \RuntimeException($e->getMessage(), 0, $e)
: new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
}A plain exception is retried by the retry strategy, up to max_retries. UnrecoverableMessageHandlingException skips the retries and goes straight to the failed transport: in the test it made one attempt and logged Removing from transport after 0 retries.
Do not reach for RecoverableMessageHandlingException here, even though its name suggests it. In Symfony 8.1.8 it retries whatever max_retries says unless you pass false as its fifth argument. With max_retries at 3 it made 7 attempts in 14 seconds in one test, and the message was still queued afterwards. In the 7.4 source it has no such argument and always retries. A plain exception behaves the same on both.
Step 5: write the handler
The handler fetches the login page with Symfony’s HttpClient, reads the CSRF token and the sitekey, asks CapSkip for a token and posts the form. On this setup HttpClient has no cookie jar, so the handler copies the first Set-Cookie header into the post by hand. On Django that is the cookie that goes with the CSRF token; join several if your site sets more. It also sends the Referer, because some frameworks, such as Django over HTTPS, reject a form post without one. Replace the paths and field names with the ones your site uses.
<?php
// src/MessageHandler/SolveLoginHandler.php
namespace App\MessageHandler;
use App\CapSkipFailure;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final class SolveLoginHandler
{
private CapSkip $solver;
public function __construct(
private HttpClientInterface $http,
#[Autowire(env: 'CAPSKIP_HOST')] string $host,
#[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
#[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
#[Autowire(env: 'SITE_PASSWORD')] private string $password,
) {
$this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
}
public function __invoke(SolveLogin $message): void
{
$url = $message->siteUrl . '/login';
$page = $this->http->request('GET', $url);
$cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
$doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
$csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
$sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');
try {
$token = $this->solver->recaptcha($sitekey, $url)['code'];
} catch (CapSkipError $e) {
throw CapSkipFailure::toMessenger($e);
}
$reply = $this->http->request('POST', $url, [
'headers' => ['Referer' => $url, 'Cookie' => $cookie],
'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
'password' => $this->password, 'g-recaptcha-response' => $token],
]);
if ($reply->getStatusCode() >= 400) {
throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
}
// Save the result here (your own code).
}
}The recaptcha method returns an array whose code entry is the token. For other CAPTCHA types, call the matching SDK method and keep the rest. The guide to solving reCAPTCHA v2 in PHP covers the same call outside a queue.
The handler treats status 400 and above as a failure. A site that answers a wrong password with status 200 and the login form again needs a check on the page itself, or the ledger further down will record the job as done. A rejected login throws a plain exception here, so it is retried like the rest, and a wrong password costs four solves. Throw UnrecoverableMessageHandlingException for any response you know is final.
Step 6: dispatch a message and start the worker
A small console command puts one message on the queue. Symfony 8 console commands can be plain invokable classes, as here.
<?php
// src/Command/SolveLoginCommand.php
namespace App\Command;
use App\Message\SolveLogin;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(name: 'app:solve-login', description: 'Queue one SolveLogin message')]
final class SolveLoginCommand
{
public function __construct(private MessageBusInterface $bus)
{
}
public function __invoke(#[Argument] string $siteUrl, #[Argument] string $username): int
{
$this->bus->dispatch(new SolveLogin($siteUrl, $username));
return 0;
}
}Queue a login and start the worker. The transport creates its table on first use. Replace https://example.com with a page of your own that carries a reCAPTCHA: with the placeholder the page fetch fails, and the job goes into the retry loop.
php bin/console app:solve-login https://example.com YOUR_USERNAME php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M
Without -vv the worker prints almost nothing per message, and Symfony’s console log has no timestamps, so add your own if you need them. The memory limit is safe: the worker sat at about 12 MB. The time limit needs a warning, covered next.
There is no time limit for one message
Messenger has no per-message timeout. The worker’s time limit option is only checked between messages, so it cannot cut a handler short: with two queued messages, a 20 second solve and a time limit of 5 seconds, the worker lived 23.1 seconds, finished the first message and left the second queued. Setting max_execution_time does not help either. The command-line PHP ignores the value in php.ini, and forcing it with the -d option or set_time_limit only produced a fatal error after the blocked call had already returned, at 120.1 seconds.
What bounds a stuck solve is the SDK itself. The PHP SDK gives each HTTP call a 30 second connect limit and a 120 second total limit, and it offers no option to change them. A CapSkip that accepted the connection and never answered the submit cost the worker 120.0 seconds, then raised a NetworkException. One that took the task and then never answered the result polls cost 362.1 seconds. The SDK treats each silent poll as not ready, so three polls of 120 seconds each had passed before it noticed that recaptchaTimeout, 300 seconds, was over, and raised a TimeoutException.
A poll that goes quiet just before the deadline can run its full 120 seconds past it. With recaptchaTimeout at 20, a CapSkip that went silent after 14 seconds ended the solve at 142.8 seconds. Every one of these ends in an exception that the mapping from Step 4 retries. They set the number to plan around: with the default settings a handler can take up to about 9 minutes, which is 120 seconds for the submit, 300 for polling and 120 for the last poll, plus the page fetch and the post.
Why redeliver_timeout decides whether you solve twice
The Doctrine transport marks a message as delivered and keeps it in the table until the handler finishes. If a message has been delivered for longer than redeliver_timeout, any worker may take it again, whether or not the first one is still working. The default is 3600 seconds.
In a test with redeliver_timeout at 5 seconds and a solve that took 15, a second worker received the same message 5.6 seconds after the first. CapSkip solved twice and the site accepted two posts. The test site’s single-use token check did not stop it, because each worker had solved its own token; it only rejects one token used twice. When the second worker finished, its acknowledgement deleted no rows and raised no error. With the timeout at 40 seconds, the same test made one solve and one post.
So the value must be longer than your slowest handler. With the SDK limits above, a handler can take about 9 minutes, so the messenger.php file in Step 2 sets 900 seconds. The price is that a message held by a crashed worker waits that long before another worker picks it up. A lower value is a safety net, not a fix. In a test where the first worker’s solve hung, a 20 second timeout let a second worker take over and finish. The hung worker then failed at 120 seconds and scheduled its own retry, so the job was solved a second time and the site accepted two posts.
Stop the worker without losing the solve
Messenger normally listens for SIGTERM and SIGINT through pcntl, so Ctrl+C means finish the message, then stop. PHP on Windows has no pcntl. A real Ctrl+C event killed the worker in the middle of a solve within 0.01 seconds, with exit code 0xC000013A and no log line. The message stayed in the table with its delivered time set, so with the default timeout no other worker would see it for an hour.
With redeliver_timeout at 10 seconds, a second worker received the message about 22 seconds after it was queued and solved it again, which left the first solve wasted.
The fix is a listener that installs the Windows console handler when the worker starts.
<?php
// src/EventListener/WindowsGracefulStop.php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerStartedEvent;
#[AsEventListener]
final class WindowsGracefulStop
{
public function __invoke(WorkerStartedEvent $event): void
{
// Without pcntl, Ctrl+C kills the process at once. This turns it into "finish the message, then stop".
if (function_exists('sapi_windows_set_ctrl_handler')) {
$worker = $event->getWorker();
sapi_windows_set_ctrl_handler(static fn () => $worker->stop());
}
}
}With it, Ctrl+C logged Stopping worker, the worker finished the solve and the post, acknowledged the message and exited with code 0 about 15 seconds later. The keepalive option does not help on Windows: it needs pcntl and stops with Signals are not supported. The messenger:stop-workers command does work between two processes. An idle worker stopped in 0.3 seconds, and a busy one finished its message first and left the next one queued.
The listener has a cost. Ctrl+C no longer aborts a stuck solve, because it now means stop after this message, and pressing it again does not force the exit. In a test, four presses two seconds apart still waited for a 20 second solve to finish. If CapSkip has gone silent, that can be several minutes, so end the process from Task Manager when you need it gone at once.
Keep the worker running
A worker is meant to exit now and then, because the time limit and memory limit options make it stop on purpose. A loop in PowerShell starts a new one each time. It runs the worker through cmd so the log file is plain text; redirecting a native command in Windows PowerShell 5.1 writes UTF-16 and adds error noise.
while ($true) {
cmd /c "php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M >> var\worker.log 2>&1"
Start-Sleep -Seconds 5
}In testing, the loop started a new worker after a time-limit exit and after the process was killed. Starting the loop at sign-in, for example from Task Scheduler or as a service, was not tested here.
Do not solve the same job twice
Retries repeat the whole handler. If the form post succeeds and a later step throws, for example when you save the result, the next attempt solves and posts again. In a test with a save step that always failed after the post, the handler from Step 5 made 4 solves and 4 accepted posts for one message.
A small ledger fixes it. The job id from the message goes into a table straight after the post, and a retry that finds the id skips the solve and runs only the part that failed.
<?php
// src/JobLedger.php (SQLite syntax: INSERT OR IGNORE)
namespace App;
use Doctrine\DBAL\Connection;
final class JobLedger
{
public function __construct(private Connection $db)
{
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS job_done (job_id TEXT PRIMARY KEY, status INTEGER NOT NULL)');
}
public function status(string $jobId): ?int
{
$status = $this->db->fetchOne('SELECT status FROM job_done WHERE job_id = ?', [$jobId]);
return $status === false ? null : (int) $status;
}
public function done(string $jobId, int $status): int
{
$this->db->executeStatement('INSERT OR IGNORE INTO job_done (job_id, status) VALUES (?, ?)', [$jobId, $status]);
return $status;
}
}The second version of the handler asks the ledger first. Replace the file from Step 5 with this one.
<?php
// src/MessageHandler/SolveLoginHandler.php (second version: runs the login once per job)
namespace App\MessageHandler;
use App\CapSkipFailure;
use App\JobLedger;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final class SolveLoginHandler
{
private CapSkip $solver;
public function __construct(
private HttpClientInterface $http,
private JobLedger $ledger,
#[Autowire(env: 'CAPSKIP_HOST')] string $host,
#[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
#[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
#[Autowire(env: 'SITE_PASSWORD')] private string $password,
) {
$this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
}
public function __invoke(SolveLogin $message): void
{
// A retry after the POST went through skips the solve and the POST and repeats only the cheap part.
$status = $this->ledger->status($message->jobId)
?? $this->ledger->done($message->jobId, $this->login($message));
// Save the result here (your own code), using $status.
}
private function login(SolveLogin $message): int
{
$url = $message->siteUrl . '/login';
$page = $this->http->request('GET', $url);
$cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
$doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
$csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
$sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');
try {
$token = $this->solver->recaptcha($sitekey, $url)['code'];
} catch (CapSkipError $e) {
throw CapSkipFailure::toMessenger($e);
}
$reply = $this->http->request('POST', $url, [
'headers' => ['Referer' => $url, 'Cookie' => $cookie],
'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
'password' => $this->password, 'g-recaptcha-response' => $token],
]);
if ($reply->getStatusCode() >= 400) {
throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
}
return $reply->getStatusCode();
}
}With the ledger, the same failing save step made 1 solve and 1 post, and the retries went straight to the save. The ledger does not protect against two workers running the same job at the same time. Only a long enough redeliver_timeout does that. The job_done table gets one row per job, so delete old rows now and then.
Running CapSkip on another machine
The worker and the solver do not have to share a PC. Switch CapSkip to Server mode under connection settings, and it listens on your network address or public IP. Then set CAPSKIP_HOST in .env or .env.local to that address.
Use a static public IP if the route crosses the internet, and turn on API key validation, with the same key in CAPSKIP_API_KEY. CapSkip opens its port to every address when Server mode starts, so to limit access to the machines you expect, add a Windows Firewall block rule for the others. The solver is still your own Windows machine, and solves are still unmetered.
Common Symfony Messenger CAPTCHA errors
| What you see | Cause | Fix |
|---|---|---|
| No transport supports Messenger DSN doctrine://default | doctrine-bundle is missing, even though the error names doctrine-messenger | Require doctrine/doctrine-bundle |
| An exception occurred in the driver: could not find driver | pdo_sqlite is off, or the recipe’s PostgreSQL DATABASE_URL is still in .env. Both give the same error text | Enable the extension and replace the URL |
| no such table: messenger_messages | The DSN still has the recipe’s auto_setup=0, so nothing creates the table | Remove auto_setup=0, or run messenger:setup-transports |
| unable to open database file | A relative SQLite path | Start the path from %kernel.project_dir% |
| The doctrine/orm package is required when the doctrine.orm config is set | The recipe’s doctrine.yaml is still there | Delete it and use the PHP file from Step 2 |
| You have requested a non-existent parameter | doctrine.yaml was deleted and no doctrine.php replaced it | Add the doctrine.php file |
| Invalid Messenger routing configuration: class not found | The message class file is missing or has the wrong namespace | Create src/Message/SolveLogin.php as in Step 3 |
| Unknown option found in DSN: [redeliver_timout] | A misspelled option; the message lists the valid ones | Fix the spelling |
| Removing from transport after 3 retries, then the message is gone | No failed transport is configured | Set failure_transport as in Step 2 |
| More retries than max_retries | RecoverableMessageHandlingException is in use | Throw a plain exception, as in Step 4 |
| The worker exits on Ctrl+C with no log line | No pcntl on Windows | Add the console handler listener |
| The same login was posted twice | redeliver_timeout is shorter than the handler, or a step after the post failed | Raise the timeout and add the ledger |
| Signals are not supported | The keepalive option needs pcntl | Leave that option off on Windows |
FAQ
Does this work on Symfony 7.4 LTS?
The blocks were run on Symfony 8.1.8 and PHP 8.4.15. Symfony 7.4 needs PHP 8.2 or later, and the handler uses PHP 8.4’s HTML parser, so on older PHP you would swap in another parser. The retry design is the same, and a plain exception behaves the same on both versions. Only RecoverableMessageHandlingException differs, as Step 4 explains. The 7.4 behaviour comes from reading its source, not from running it.
What happens to a message that fails every retry?
With the failed transport from Step 2 it moves into the same table under the queue name failed. Read it with messenger:failed:show, send it again with messenger:failed:retry, and drop it with messenger:failed:remove. The retry command runs the handler in your terminal. Without a failed transport those commands do not exist, and the message is deleted. Messenger logs the final failure as critical, with the line Removing from transport after 3 retries.
Can I run more than one worker?
Yes. Start messenger:consume in a second window and each worker takes different messages. Two workers ran against the same SQLite file in the tests without errors. CapSkip’s own limit applies on top: its reCAPTCHA solver runs 10 tasks at once by default, and the rest wait their turn.
How is this different from Laravel queues?
Laravel’s queue:work has a per-job timeout, but it needs pcntl, so on Windows it is silently ignored, as the guide to solving CAPTCHAs in Laravel queues explains. Messenger has no per-job timeout on any platform. Its risks are the retry exception, the redelivery timeout and the missing signal handling.
The short version
A Symfony Messenger CAPTCHA worker needs more than a handler. Send the inputs, not the token, and solve inside the handler. Throw a plain exception to retry and UnrecoverableMessageHandlingException to stop, and add a failed transport so nothing disappears. Set redeliver_timeout above the longest handler, which is about 9 minutes with the SDK’s limits, and add the Windows console handler so Ctrl+C finishes the solve. The Symfony Messenger documentation covers every option used here.
- Every CAPTCHA type the PHP package handles: the PHP CAPTCHA solver page.
- How the checkbox reCAPTCHA works and what the solver covers: the reCAPTCHA v2 solver page.
A queue that clears a few hundred logins a day makes thousands of solves a month. Running them through a captcha solver on your own Windows machine means that number never turns into a per-solve bill.
