dskripchenko / laravel-local-captcha
A captcha decided between the browser and your own application: proof of work, timing and a honeypot, with no third-party service.
Package info
github.com/dskripchenko/laravel-local-captcha
pkg:composer/dskripchenko/laravel-local-captcha
Requires
- php: ^8.2
- ext-json: *
- laravel/framework: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
This package is auto-updated.
Last update: 2026-08-13 07:51:16 UTC
README
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.
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 |
route.middleware |
throttle:300,1 |
Rate limit on issuing (see below) |
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.
Why the rate limit is loose
Issuing a challenge costs one random number and two hashes and stores nothing — a design goal, so that a public endpoint could not be turned into a resource to exhaust. There is very little for a tight limit to protect.
What a tight limit does break is ordinary visitors. A page carrying the form asks for a challenge on every load, and behind a corporate NAT many people arrive as one address. They hit the limit, the challenge never arrives, the puzzle never solves, and the form silently stops working with nothing on screen to explain it — the exact failure mode this package exists to avoid.
The first default of 60/minute was too tight: a routine end-to-end run tripped it from a single address.
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.