How to Solve CAPTCHAs in a Laravel Queue Job (PHP SDK)

laravel queue captcha - How to Solve CAPTCHAs in a Laravel Queue Job (PHP SDK)

To solve a CAPTCHA in a Laravel queue job, call the CapSkip PHP client inside the job’s handle method and submit the token from that same job. The call is short. What needs care is three numbers Laravel ships for jobs that finish in seconds: the worker gives each job 60 seconds, the queue hands a job to another worker after 90, and every job gets one attempt. A reCAPTCHA solve can poll for up to 300 seconds, so on the defaults a laravel queue captcha job is either killed halfway or marked failed while it is still solving, and once you add retries it can run twice. This guide covers the job, the timeouts, a retry rule, and the one thing that changes when the worker runs on Windows.

What you need

  • Laravel 11 or later. The job uses the single Queueable trait that 11 introduced; everything else here works on 10 as well. Laravel 13 can also express the job settings as attributes such as #[Tries(3)], and the plain properties used below still work there.
  • The CapSkip PHP package, which needs PHP 8.0 or later with the curl and json extensions. Check that curl is enabled in the php.ini your worker actually loads, because on Windows it is sometimes left commented out.
  • A queue connection. New Laravel apps use the database driver, and the examples below do too.
  • CapSkip running on a Windows machine. In Local mode it answers on 127.0.0.1 for that machine only, which suits a worker on the same PC. In Server mode it listens on your network address or public IP, so a Laravel app on a Linux server, Forge or any other host can reach it over the API. Both are set under connection settings.
# Run in the Laravel project root
composer require capskip/capskip

Step 1: register the client in the container

Keep the solver’s address in configuration rather than in the job, so the same code runs against a local solver and a server one. Add a block to config/services.php and a singleton binding to your service provider.

// config/services.php
'capskip' => [
    'key' => env('CAPSKIP_API_KEY', 'capskip'),
    'host' => env('CAPSKIP_HOST', '127.0.0.1'),
    'port' => (int) env('CAPSKIP_PORT', 8080),
],

// app/Providers/AppServiceProvider.php, inside register()
$this->app->singleton(\CapSkip\CapSkip::class, fn () => new \CapSkip\CapSkip([
    'apiKey' => config('services.capskip.key'),
    'host' => config('services.capskip.host'),
    'port' => config('services.capskip.port'),
]));

The client never reads environment variables by itself, which is why the binding passes each value in. Read them through config() rather than env() anywhere else in the app: once you run config:cache, env() outside the config files returns null.

Step 2: solve and submit in one job

Type-hint the client on handle() and Laravel injects the singleton. The job reads the sitekey off the page, solves, and posts the form.

<?php

namespace App\Jobs;

use CapSkip\CapSkip;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

class SubmitSignup implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $pageUrl, public array $fields) {}

    public function handle(CapSkip $solver): void
    {
        $html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
        preg_match('/data-sitekey="([^"]+)"/', $html, $m);

        // Solve and submit in one job: the token expires in two minutes.
        $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];

        Http::asForm()->timeout(30)
            ->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
            ->throw();
    }
}

Dispatch it with the page and the form’s other fields, onto its own connection and queue, which the next step defines:

SubmitSignup::dispatch('https://example.com/signup', ['email' => 'YOUR_EMAIL'])
    ->onConnection('captcha')
    ->onQueue('captcha');

Keep the solve and the submit together. A reCAPTCHA token is valid for about two minutes, and a token handed to a second, chained job can spend that time waiting behind other work. How long a reCAPTCHA token stays valid covers the clock in detail. The PHP client is synchronous, so the worker process is busy for the whole solve; the way to run solves side by side in Laravel is more worker processes, not a different client.

Step 3: line up the timeouts

Four clocks run on a Laravel queue CAPTCHA job. From the inside out:

  • CapSkip’s own settings. A reCAPTCHA task can wait up to 250 seconds for a free thread (Wait Timeout) and gets 250 seconds to solve (Row Timeout). When either runs out, CapSkip fails the task and the client raises an ApiException, unless the client’s own limit below has fired first.
  • The client’s polling. recaptcha() polls for up to recaptchaTimeout, 300 seconds by default, then raises a TimeoutException. With the two HTTP calls around it capped at 30 seconds each above, the whole handle() finishes within about six minutes when CapSkip answers normally. The 300 second deadline is checked between polls, though, and each request the client sends can hang for up to 120 seconds, so a CapSkip that stops answering can stretch a job past that.
  • The job timeout. The worker’s timeout option defaults to 60 seconds, and a job that runs longer gets its worker process killed. Set a $timeout on the job that is longer than the six minutes above.
  • retry_after. Each queue connection releases a reserved job back to the queue once it has been running this long, 90 seconds by default. If the job has attempts left, a slow solve still in progress on one worker then starts again on another, which means a second solve and a second form submission. If it has none, the second worker marks it failed at once, while the first may still go on to submit the form.

Laravel’s documentation says the timeout should always be at least several seconds shorter than retry_after. So the order is: client first, job timeout second, retry_after last. Give CAPTCHA jobs a connection of their own, so the long retry_after does not slow the recovery of every other job in the app:

// config/queue.php, under 'connections'
'captcha' => [
    'driver' => 'database',
    'connection' => env('DB_QUEUE_CONNECTION'),
    'table' => env('DB_QUEUE_TABLE', 'jobs'),
    'queue' => 'captcha',
    // Longer than the job's timeout, so no second worker takes it.
    'retry_after' => 420,
    'after_commit' => false,
],

// On the job class
public $timeout = 390;

Run the worker against that connection and queue:

php artisan queue:work captcha --queue=captcha

The job’s $timeout outranks the worker’s own timeout option, so the command needs none.

Step 4: retry only what a retry can fix

A job gets one attempt unless you say otherwise, so any exception fails it for good. Some CAPTCHA failures are worth another try: an ERROR_CAPTCHA_UNSOLVABLE result, a TimeoutException, or a NetworkException because CapSkip was restarting. Others never succeed on a repeat: a wrong API key, a malformed sitekey (ERROR_GOOGLEKEY), or a parameter the client rejects. A sitekey Google refuses comes back as ERROR_CAPTCHA_UNSOLVABLE, so it uses up the retries like any other unsolvable result. Allow three attempts, spread them out, and fail the hopeless cases straight away.

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;

// On the job class
public $tries = 3;
public $backoff = [30, 120];

// Inside handle(), around the solve
try {
    $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
} catch (ValidationException $e) {
    $this->fail($e);   // a bad parameter will not fix itself
    return;
} catch (ApiException $e) {
    if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
        $this->fail($e);   // a wrong API key and the like
        return;
    }
    throw $e;   // retried after the backoff
}

$this->fail() marks the job failed without spending the remaining attempts, and anything rethrown is retried after 30 seconds, then 120. A job killed by its timeout also uses up an attempt, but it is picked up again only when retry_after expires, not after the backoff. A 4xx from the form also throws, and the retry solves and posts again. If the site answers 4xx for bad input, catch Illuminate\Http\Client\RequestException around the post and call $this->fail($e) when $e->response->clientError() is true.

Running the worker on Windows

Running Laravel on the same Windows PC as CapSkip is the simplest setup, and it has one trap. Laravel enforces job timeouts with the pcntl extension, and pcntl does not exist on Windows. This prints bool(false) there:

php -r "var_dump(extension_loaded('pcntl'));"

Without pcntl, the job’s $timeout and the worker’s timeout option are silently ignored and a job runs until it returns, however long that takes. retry_after still applies, because it is checked when the next worker pulls a job, not enforced by a signal. So on Windows nothing in Laravel stops a slow job. CapSkip’s own Wait and Row Timeouts and the client’s 300 second polling limit are what end a solve, so leave them in place. Keep retry_after above the longest a job can take: 420 covers a CapSkip that answers normally, and 660 also covers a connection that stalls, for example over Server mode across the internet.

Two other things differ. Laravel Horizon requires the pcntl and posix extensions, so it will not install on Windows; use plain queue:work there, or run Horizon on a Linux host that calls CapSkip in Server mode. And there is no Supervisor, so run each worker under a scheduled task or a service wrapper that restarts it. On Linux, Supervisor’s stopwaitsecs must be longer than your longest job, or a deploy kills a solve partway through. In both cases, after a deploy or an .env change, run php artisan config:cache if you cache configuration, then php artisan queue:restart, since a worker keeps the configuration it booted with.

Each worker process solves one CAPTCHA at a time, and CapSkip runs up to 10 reCAPTCHA solves at once by default (Max. Threads in its reCAPTCHA settings). More than about ten workers on the captcha queue only makes jobs wait inside CapSkip, where the time counts against Wait Timeout.

Full working example

<?php

namespace App\Jobs;

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\ValidationException;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;

class SubmitSignup implements ShouldQueue
{
    use Queueable;

    public $tries = 3;
    public $backoff = [30, 120];
    // GET 30 s + solve up to 300 s + POST 30 s, plus a margin.
    public $timeout = 390;

    public function __construct(public string $pageUrl, public array $fields) {}

    public function handle(CapSkip $solver): void
    {
        $html = Http::timeout(30)->get($this->pageUrl)->throw()->body();
        if (! preg_match('/data-sitekey="([^"]+)"/', $html, $m)) {
            $this->fail(new RuntimeException("No sitekey on {$this->pageUrl}"));
            return;
        }

        try {
            $token = $solver->recaptcha($m[1], $this->pageUrl)['code'];
        } catch (ValidationException $e) {
            $this->fail($e);
            return;
        } catch (ApiException $e) {
            if (! str_contains($e->getMessage(), 'UNSOLVABLE')) {
                $this->fail($e);
                return;
            }
            throw $e;
        }

        // Submit in the same job, while the token is still valid.
        Http::asForm()->timeout(30)
            ->post($this->pageUrl, $this->fields + ['g-recaptcha-response' => $token])
            ->throw();
    }

    public function failed(?Throwable $exception): void
    {
        logger()->warning('Signup gave up', [
            'url' => $this->pageUrl,
            'error' => $exception?->getMessage(),
        ]);
    }
}

Pair it with the captcha connection from Step 3 and the binding from Step 1, dispatch it as shown in Step 2, and start a worker with the command from Step 3. The sitekey pattern expects the double-quoted data-sitekey attribute that reCAPTCHA’s own snippet writes. For an invisible or Enterprise widget, pass the matching option to recaptcha(), as described on the reCAPTCHA v2 solver page; the raw endpoint behind the call is in the API reference.

Common errors and what they mean

What you seeCauseFix
"has timed out" after 60 seconds, and the job is marked failedThe worker’s default timeout is 60 seconds and the job has one attemptSet $timeout on the job, and $tries if you want retries
The form is submitted twice, or two solves run for one jobThe job ran longer than retry_after, 90 seconds by default, and a second worker took itPut CAPTCHA jobs on a connection with a retry_after longer than $timeout
On Windows, a job runs well past its $timeoutJob timeouts need pcntl, which Windows does not haveRely on CapSkip’s own timeouts and the client’s 300 second limit, and keep retry_after above the whole job
"has been attempted too many times"The job was picked up again after its worker died or outran retry_after, more often than $tries allowsLook for workers killed mid-job by a deploy, and for jobs that outlive retry_after
ApiException containing ERROR_CAPTCHA_UNSOLVABLECapSkip failed the task, for example when it ran past Row TimeoutRethrow it so Laravel retries after the backoff
ApiException containing ERROR_KEY_DOES_NOT_EXISTAPI key validation is on in CapSkip and CAPSKIP_API_KEY does not match a key thereFix the key and restart the workers; fail the job rather than retry it
NetworkException on the first solve after changing .envThe worker still runs with the old host, or CapSkip is not runningRun php artisan config:cache if you cache configuration, then php artisan queue:restart, and check Local or Server mode
Call to undefined function curl_init()The curl extension is off in the php.ini the worker loadsEnable extension=curl in that php.ini
The page request times out while the job "runs"The job was dispatched without the captcha connection and QUEUE_CONNECTION is sync, so it ran inside the web requestDispatch onto the captcha connection as in Step 2, and run a worker on it
The site rejects the tokenIt was submitted after it expired, or to the wrong URLSubmit from the same job straight after the solve, to the form’s action URL

FAQ

Why not solve the CAPTCHA inside the controller?

Because a solve takes tens of seconds and can take several minutes. That is longer than a visitor will wait and longer than the 30 second max_execution_time most PHP installs give a web request. Queueing the job lets the controller return the response at once, while the slow part runs in a worker process that has no such limit.

Does a Laravel queue CAPTCHA job run under Horizon?

Yes, on a host that has pcntl, which means Linux or macOS. Horizon sets the timeout per supervisor in config/horizon.php, so give the supervisor that runs the captcha queue a timeout a little above the job’s 390 seconds, such as 400, because with the auto balancing strategy Horizon stops workers that outlast its own timeout when it scales down. Keep the Redis connection’s retry_after above that (420 works), and point CAPSKIP_HOST at the Windows machine running CapSkip in Server mode.

Can a Laravel app on a Linux server use CapSkip on my Windows PC?

Yes. Switch CapSkip to Server mode under connection settings so it listens on a network address, set CAPSKIP_HOST in the app’s .env, and restart the workers. Use a static public IP and a firewall rule that admits only your server if the route crosses the internet, and turn on API key validation. The solver stays on your own Windows machine, so nothing changes about how solves are counted.

How does this compare with Hangfire or Celery?

The shape is the same everywhere: solve and submit in one job, retry only the failures a retry can fix, and size the workers to CapSkip’s thread count. What differs is the default that bites. Laravel gives one attempt and hands a slow job to a second worker, which fails it or, once you allow retries, runs it again; Hangfire retries ten times over hours. The .NET version is in the Hangfire CAPTCHA job guide.

The short version

For a Laravel queue CAPTCHA job, bind the CapSkip client in the container, and solve and submit in the same job. Give CAPTCHA jobs their own connection with a retry_after of 420 seconds, set $timeout to 390 and $tries to 3 with a backoff, and fail the errors a retry cannot fix. On Windows, remember that no timeout is enforced without pcntl, and restart the workers after every deploy.

Retries are where these settings pay off. A job that retries an unsolvable CAPTCHA twice is ordinary when the captcha solver runs on your own machine, because each extra attempt costs worker and thread time there rather than another metered solve.