Looking to hire Laravel developers? Try LaraJobs
This package is not available.

laravel-local-captcha maintained by dskripchenko

Description
A captcha decided between the browser and your own application: proof of work, timing and a honeypot, with no third-party service.
Last update
2026/08/13 08:27 (dev-master)
License
Links
Downloads
101

Comments
comments powered by Disqus

laravel-local-captcha

A captcha decided entirely between the visitor's browser and your own application. No third-party service, no account to hold, no key to rotate, no visitor data handed to anyone.

🌐 English · Deutsch · Русский · 中文

What it stops, and what it does not

Read this before choosing it. The trade is real in both directions.

It stops scripted mass submitters — the form-fillers sweeping the internet, which is nearly all of the automated traffic a small site actually sees. They do not run JavaScript, do not wait, and fill every field they find. Three independent layers each catch that.

It does not stop someone who reads this code. Neither does proof-of-work in general: a puzzle calibrated to be tolerable on a phone is trivial for a server. That gap is not a flaw in the calibration, it is the honest limit of the technique.

It does not stop human solving farms. Nothing does, including the hosted services.

It has no reputation signal, and cannot have one. What makes reCAPTCHA, Turnstile and SmartCaptcha strong is seeing the same browser across millions of sites. This sees one submission on one form. That is not a smaller version of the same thing — it is a different thing, and no amount of work here closes the gap.

What you get instead: it cannot be blocked by geography, because there is nothing to block; it works in an air-gapped or firewalled deployment, where a hosted captcha simply never loads; and it sends nothing about your visitors anywhere, which removes a data-residency question rather than answering one.

Requirements

PHP 8.2–8.5 · Laravel 11 / 12 / 13.

Proof of work requires JavaScript in the browser. So does every hosted captcha, so this is not a concession this package makes and the others avoid — but if your form must work without JavaScript, turn that layer off and rely on the other two.

Install

composer require dskripchenko/laravel-local-captcha
php artisan vendor:publish --tag=local-captcha-assets

The service provider is auto-discovered. Publish the config if you want to edit it directly rather than through the environment:

php artisan vendor:publish --tag=local-captcha-config

Use

Put the component in the form and the rule in the validation:

<form method="POST" action="/leads" data-local-captcha>
    @csrf
    <input name="email" type="email">

    <x-local-captcha-field />

    <button type="submit">Send</button>
</form>
$request->validate([
    'email' => 'required|email',
    'captcha' => 'local_captcha',
]);

The component renders the hidden solution field, the decoy, and the script tag. The script fetches a challenge on page load and solves it while the visitor is still typing, so the work overlaps with filling the form instead of becoming a wait after the button is pressed.

To drive it yourself:

LocalCaptcha.attach(document.querySelector('#my-form'), {
    endpoint: '/local-captcha/challenge',
    field: 'captcha',
});

The form emits local-captcha:ready, local-captcha:failed and local-captcha:blocked so you can show progress or an error.

How it works

The server picks a number in [0, max] and publishes the hash of it, together with a random salt, an expiry, and an HMAC signature over all of it. The browser finds the number by trying them in order. The server checks the answer with a single hash.

Two properties of that shape are worth naming, because the more familiar "leading zeros" proof-of-work does not have them:

  • The work is bounded. A leading-zeros target has no upper limit on attempts, so an unlucky visitor can spin for many times the average while the median visitor sees nothing wrong. A range gives a worst case you can state, which is what makes the difficulty setting answerable.
  • Issuing costs nothing and stores nothing. Challenges are signed, not saved. Only spent challenges are recorded, and only for as long as they could still be answered. Storing at issue time would turn the public challenge endpoint into a way to fill your cache — cheap for the caller, expensive for you.

Each layer refuses on its own:

Layer Catches
pow Clients that do not run JavaScript, and replayed or forged challenges
timing Submissions faster than a person could have filled the form in
honeypot Anything that fills a field hidden from view

Configure

LOCAL_CAPTCHA_MAX=30000
LOCAL_CAPTCHA_MIN_SECONDS=3
LOCAL_CAPTCHA_LIFETIME=900
Key Default Meaning
enable true Turn the whole check off (local development, tests)
layers.pow true Browser solves the search
layers.timing true Minimum time between issue and submit
layers.honeypot true Decoy field
max 30000 Size of the search space
lifetime 900 Seconds a challenge stays answerable
min_seconds 3 Least believable fill-in time
route.uri local-captcha/challenge Where challenges are issued; null registers no route
fields.solution captcha Request field carrying the solution
fields.honeypot website Name of the decoy
cache.store default store Where spent challenges are remembered

Calibrating max

Set it low. Lower than feels right.

Against the traffic this actually stops, the difficulty does not matter at all: a scripted form-filler does not run JavaScript, so it fails at any setting, including the smallest one. Difficulty only bites an attacker driving a headless browser — and against that, no setting a visitor would tolerate is enough anyway. So every millisecond of it is paid by real visitors and none of it by the bots you are catching.

That leaves device spread as the only thing to weigh. Measured on a desktop at 100000: median 263 ms, worst of eight runs 388 ms, page load included. A mid-range phone is a handful of times slower than that; an old one can be fifty, which puts the same setting into double-digit seconds for the visitor least likely to wait.

30000 is a reasonable default for a public form: imperceptible on a desktop, a few seconds at worst on old hardware. Raise it only if you have a measured reason to, and remember what the extra work does not buy.

The cache store must be shared

Spent challenges live in the cache. Every process serving the site has to see the same one — with a per-process store on more than one server, each remembers different challenges and a solution works once per server.

Verdicts

LocalCaptcha::verify() returns a Verdict, not a boolean, so a refusal can be logged with its reason. This matters operationally: a rise in expired means visitors are taking longer than the lifetime allows, while a rise in replayed means someone is automating against you. Those call for opposite responses, and a boolean cannot tell them apart.

passed · malformed · forged · expired · replayed · wrong · too_fast · honeypot

Tests

composer test
php tests/browser-contract.php   # needs node

The second one matters more than its size suggests. Everything the PHPUnit suite can reach is PHP re-implementing what the browser is supposed to do, and a suite like that stays green while the two sides drift apart. browser-contract extracts the worker from the file that actually ships and runs it against a real challenge.

License

MIT — see LICENSE.md.