gregwar/captcha-bundle
GregwarCaptchaBundle adds a captcha form type to Symfony forms using gregwar/captcha. It renders a configurable captcha image, embedded by default, with options for width, height, and more. Supports Symfony 6–8 and PHP 8+.
First, settle the framework question. gregwar/captcha-bundle is a Symfony Form Component bundle. It has no Laravel service provider, no config/captcha.php, no Blade views. composer require gregwar/captcha-bundle inside a Laravel app will pull symfony/form, symfony/framework-bundle, and friends, and then do nothing for you. The portable piece — and the only thing you actually want — is the underlying standalone library:
composer require gregwar/captcha
# ensure the runtime has GD with FreeType
php -m | grep -i gd
That library gives you Gregwar\Captcha\CaptchaBuilder and Gregwar\Captcha\PhraseBuilder: render an image, get the phrase back, decide yourself how to store it. Everything the Symfony bundle does — session binding, image embedding vs. URL serving, GC of generated files, the captcha form type — you re-implement in about 80 lines of Laravel. Budget a day, not a sprint.
Where to look first, in order:
width, height, length, charset, quality, distortion, max_front_lines/max_behind_lines, background_color, bypass_code, humanity, invalid_message, keep_value, session_key. Read it as a requirements doc, not as API you can call.gregwar/captcha library README for the builder API (build(), getPhrase(), inline(), get(), save()).PhraseBuilder for charset and length control.First use case — protect one low-traffic form. Contact form or registration. Do not start with login (session-regeneration interactions bite you, see Gotchas). The minimal working loop:
// routes/web.php
Route::get('/captcha/{key?}', CaptchaController::class)
->middleware('throttle:30,1')
->name('captcha');
// app/Http/Controllers/CaptchaController.php
public function __invoke(Request $request, string $key = 'default')
{
$phraseBuilder = new PhraseBuilder(5, 'abcdefhjkmnprstuvwxyz23456789');
$builder = new CaptchaBuilder(null, $phraseBuilder);
$builder->build(160, 50);
$builder->setDistortion(true);
$builder->setMaxBehindLines(4);
$request->session()->put("captcha.{$key}", [
'phrase' => $builder->getPhrase(),
'at' => now()->timestamp,
]);
return response($builder->get(), 200, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
'Pragma' => 'no-cache',
]);
}
Blade side:
<img src="{{ route('captcha', ['key' => 'contact']) }}?t={{ now()->timestamp }}"
width="160" height="50" alt="Verification code">
<input type="text" name="captcha" autocomplete="off" required>
That's the whole vertical slice: render → store phrase in session → compare on POST. The rest of this assessment is the hardening around it.
Pattern 1 — validation rule, not controller logic. Keep comparison out of the controller so every form gets it identically.
class CaptchaRule implements ValidationRule
{
public function __construct(private string $key = 'default') {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (config('captcha.disabled')) return;
$stored = session()->pull("captcha.{$this->key}"); // pull = single use
if (! $stored || now()->timestamp - $stored['at'] > config('captcha.ttl')) {
$fail(__('validation.captcha_expired'));
return;
}
if (! hash_equals(strtolower($stored['phrase']), strtolower((string) $value))) {
$fail(__('validation.captcha'));
}
}
}
pull() is critical — a captcha code must be burned on first use, otherwise it's replayable for the whole session lifetime.
Pattern 2 — config file mirroring the Symfony options. Translate the bundle's YAML keys once, then forget Symfony naming forever.
| Symfony bundle option | Laravel equivalent |
|---|---|
width / height / length |
config/captcha.php keys passed to build() / PhraseBuilder |
charset |
second arg to new PhraseBuilder($len, $charset) |
quality |
$builder->get('jpeg', 30) |
distortion, interpolation, max_front_lines, max_behind_lines |
setDistortion() etc. |
background_color |
$builder->setBackgroundColor(r, g, b) |
background_images + ignore_all_effects |
setBackgroundImages([...]) + setIgnoreAllEffects(true) |
disabled |
config('captcha.disabled') → App::environment('local','testing') |
bypass_code |
config('captcha.bypass'), guarded by env check |
invalid_message |
lang/en/validation.php → 'captcha' => '...' |
keep_value |
cache the rendered phrase until submit (see below) |
humanity |
probabilistic skip (see below) |
session_key |
the {key} route segment / session sub-key |
Pattern 3 — keep_value matters more than it looks. If a page re-renders (failed validation round-trip) and the <img> re-fetches, your controller overwrites the session phrase while the user's code was typed against the previous image. Symptom: "I typed it correctly and it still fails." Fix: only mint a new phrase when none exists for that key, or re-render using a cached phrase with the same TTL.
Pattern 4 — stateless / API variant. No session, no cookies (mobile clients, multi-server, SPA). Sign the phrase into the token instead:
$token = Crypt::encryptString(json_encode([
'p' => $builder->getPhrase(),
'k' => $key,
'e' => now()->addMinutes(10)->timestamp,
]));
Return { image: $builder->inline(), token: $token } — inline() gives you a data:image/jpeg;base64,... URI, which is the bundle's default embedded behaviour without any route at all. POST the token back with the answer, decrypt, check expiry, hash_equals. This is also the only sane answer for load-balanced apps, where Symfony's as_url + whitelist_key session dance exists purely to work around shared-session problems you don't have.
Pattern 5 — humanity as a conversion knob. The bundle's trick: after a user passes once, only 1 in (1+humanity) forms shows a challenge. Emulate with a session flag:
if (session('captcha.passed') && random_int(1, 1 + config('captcha.humanity')) > 1) {
return; // skip this rendering
}
This is the single highest-leverage option for form-conversion impact and the cheapest thing to A/B test.
Pattern 6 — layer, don't replace. Put the captcha behind throttle: on the generation route (image generation is CPU-bound: random lines, distortion, font rasterisation) and pair it with a honeypot field and RateLimiter on the submit route. A text-image captcha raises the cost of abuse; it does not stop a determined script.
Testing. Set CAPTCHA_DISABLED=true in phpunit.xml, or expose a test helper that seeds session(["captcha.default" => ['phrase' => 'abcde', 'at' => now()->timestamp]]) and asserts against a known phrase. Never hit the real image endpoint in feature tests — it's slow and non-deterministic.
gregwar/captcha-bundle on a Laravel app is dead weight at best and a symfony/framework-bundle conflict at worst. If you inherit a codebase that has it, the only thing worth copying is the option semantics from the README.$request->session()->regenerate() on login. If your captcha sits on the login form and the phrase is under a session key, any regeneration before comparison loses it. Either validate before Auth::attempt(), or store the phrase against a separate signed token rather than the session.as_file is a trap for Laravel. The Symfony bundle writes JPEGs to public/captcha and garbage-collects them on gc_freq/expiration. In Laravel you get orphaned files, a writable-public-disk requirement, and no GC unless you schedule it. Use inline() (data URI) or a streamed response; both are stateless and CDN-friendly. Only use files if you genuinely need Cache-Control: public images.no-store on the image response. Captcha images cached by a browser or intermediary are both a correctness bug and a security hole. Cache-Control: no-store, no-cache, must-revalidate plus a cache-busting query param on reload.$builder->output() (as opposed to get()) echoes directly and sets its own content type. Inside Laravel that fights the response lifecycle and produces a broken image with a stray byte before the JPEG header. Always use get() and wrap in response().ext-gd, no imagettftext, no image — usually as a zero-byte response with no error if output buffering swallows it. Check php -m in the container, not just locally. If you pass a custom font, it must be an absolute path to a .ttf; relative paths silently fall back to the randomised built-in fonts.strtolower() on both sides (or strcasecmp) and use hash_equals to avoid timing leakage. Also strip whitespace — mobile keyboards love a trailing space.abcdefhjkmnprstuvwxyz23456789 deliberately omits g/i/l/o/0/1. Keep it. Widening the charset for "more entropy" trades a measurable support-ticket rate for a security gain that OCR doesn't care about.bypass_code must be environment-gated and unlogged. A static code that always validates is correct for staging/QA and catastrophic in production. config('captcha.bypass') should resolve to null unless app()->environment('local','staging'), and never be a fallback when the session key is missing.RateLimiter, honeypots, and account-level reputation in the stack. No telemetry exists here — you won't know your pass/fail rates unless you log them yourself. Log captcha_failed with the form name; that metric is what tells you whether the knob is set right.{key} per form (the bundle's session_key option) and namespace the session entry accordingly.ext-gd; confirm the font path is absolute; check for a BOM or trailing newline before <?php in any file in the response path; verify you're using get() not output(); and check the response isn't being intercepted by middleware that re-encodes bodies.gregwar/captcha is actively maintained and PHP 8-clean — a decent signal for a library you're adopting without the bundle wrapper.How can I help you explore Laravel packages today?