How to solve a captcha in Laravel with local OCR

A public records portal puts a five character image captcha in front of a search form. You need to run that search a few hundred thousand times.

The reflex is to sign up for a solving service and pay per solve. Before you do, look at the image: if it is text on a noisy background, you can read it yourself, locally, for nothing.

What this does and does not cover

Covered: image captchas where a human reads characters and types them into a box. That is what public administrations, court databases and official gazettes overwhelmingly use, because they need to stay reachable and cheap to run.

Not covered: reCAPTCHA and hCaptcha image grids. Those are a different problem and OCR has nothing to say about them.

The distinction is worth ten seconds of looking, because the two get talked about as one thing and only one of them is easy.

Install the OCR side

The OCR packages are optional and deliberately not installed by default, so a project that never solves a captcha stays lean:

php artisan larascraper:install --captcha

That adds tesseract.js and jimp to the Node side. If they are missing when you call solveCaptcha(), the fetch fails with a message pointing at this command rather than something obscure.

The basic call

use EduLazaro\Larascraper\Scraper;
use EduLazaro\Larascraper\Support\ScraperResponse;

class SearchScraper extends Scraper
{
    protected function handle(string $url): ScraperResponse
    {
        return $this->scrape($url)
            ->solveCaptcha('#captcha-img', '#captcha-input', [
                'whitelist' => 'abcdefghijklmnopqrstuvwxyz0123456789',
                'psm'       => 8,
                'threshold' => 150,
            ])
            ->clickAndWait('#submit')
            ->crawl(ResultsCrawler::class)
            ->run();
    }
}

It screenshots the element at the first selector, runs OCR on it, and types the answer into the second. Everything happens inside the browser session, in order, before the final HTML is captured.

The one option that does most of the work

whitelist. Constraining the alphabet is worth more than every other tuning parameter put together, because most OCR errors are confusions between characters that look alike: 0 and O, 1 and l, 5 and S. If the captcha only ever emits lowercase letters and digits, saying so removes half the ways to be wrong.

Look at twenty captcha images before you write the string. If they are digits only, '0123456789' will do more for your hit rate than any amount of image preprocessing.

The rest, in rough order of usefulness:

threshold Binarization cutoff. Raise it when the background noise is light, lower it when the characters are thin.
psm Tesseract page segmentation mode. 8 means "one word", which is what a captcha is.
crop, scale, contrast Preprocessing, for images with a border or characters too small to resolve.
lang Tesseract language pack.

The part that actually makes it work

OCR is not going to be perfect, and designing as though it might be is the mistake. The retry loop is the feature, not the recogniser.

Wrap the attempt in repeatUntil() with a condition that describes success on the page itself:

use EduLazaro\Larascraper\Support\Condition;

return $this->scrape($url)
    ->repeatUntil(
        Condition::selectorMissing('#captcha-img'),   // gone means it was accepted
        fn ($b) => $b
            ->reload()                                // fresh captcha image
            ->solveCaptcha('#captcha-img', '#captcha-input', ['whitelist' => '0123456789'])
            ->clickAndWait('#verify'),
        max: 6,
        delay: 1500,
    )
    ->crawl(ResultsCrawler::class)
    ->run();

At a sixty percent hit rate, six attempts get you through better than ninety nine times in a hundred. You do not need a good recogniser, you need a bounded loop and a condition that tells the truth.

Three details in there that are not decoration:

reload() before each attempt. Most portals regenerate the image on reload. Retrying against the same image you just failed to read will fail identically, forever, and you will conclude that OCR does not work.

The condition describes the page, not the answer. You cannot know whether the OCR was right; you can see whether the captcha is still on screen. selectorMissing is the honest signal. Condition::textContains('Invalid code') inverted works too when the site keeps the widget and adds an error.

repeatUntil() is always bounded. max defaults to 5, is clamped to at least 1, and there is no unbounded mode. A loop that can spin forever against somebody's public portal is not a design you want to be able to write by accident.

And one behaviour worth knowing: if the body of the loop throws mid iteration, because an element was missing on a transient page, that counts as one failed attempt rather than aborting the fetch. The loop re-checks and tries again. Only when max is exhausted and the condition still never held is the last error surfaced.

Guard it, or a page without a captcha breaks the run

Portals show the captcha conditionally: after N requests, or only to some addresses. An unguarded solveCaptcha() on a page that has none is a failed action, which is a failed fetch.

->when(
    Condition::selectorExists('img[src*="captcha"]'),
    fn ($b) => $b->solveCaptcha('img[src*="captcha"]', 'input[name=captcha]'),
)

When tesseract is not enough

Distorted captchas, the ones with waves and strike-through lines, defeat tesseract and are usually read first time by a vision model:

->solveCaptcha('#captcha-img', '#captcha-input', [
    'solver' => 'vision',
    'model'  => 'gpt-4o-mini',     // the default
    // 'apiKey' => '...',          // or OPENAI_API_KEY in the environment
])

No extra Node packages, but every solve is an API call with a per-call cost, which is the whole reason the default stays 'ocr'.

It composes with the loop rather than replacing it, and it was built to. A transient 429 or 5xx from OpenAI yields an empty answer instead of throwing, so the surrounding repeatUntil() simply tries again. A bad key or a malformed request surfaces as a real error, because retrying that would only waste attempts.

The pragmatic arrangement, if volume matters, is tesseract as the default and vision as the escalation after a couple of failed passes. Most captchas cost you nothing, and the awkward ones cost a fraction of a cent instead of failing.

Where the money went

The reason to do this locally is not purity. On a corpus the size of a national gazette archive you are solving captchas in the hundreds of thousands, and per-solve pricing turns a scraping project into a line item. Tesseract on your own machine has a fixed cost you already pay.

The full option list and the file-behind-a-captcha pattern, where the loop wraps a download instead of a search, are in the captchas chapter.

written by Edu Lazaro · August 2026