Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Captcha Bundle Laravel Package

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+.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. The README Options list on the bundle repo. It is the de facto spec for the feature set you're cloning: 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.
  2. The gregwar/captcha library README for the builder API (build(), getPhrase(), inline(), get(), save()).
  3. Its 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.

Implementation Patterns

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.

Gotchas and Tips

  • Don't install the bundle. 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.
  • Session ID regeneration wipes the phrase. Laravel calls $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.
  • Set 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.
  • Headers already sent. $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().
  • GD + FreeType is a hard runtime dependency. No 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.
  • Case sensitivity. The default charset is lowercase, but users type uppercase. Compare with strtolower() on both sides (or strcasecmp) and use hash_equals to avoid timing leakage. Also strip whitespace — mobile keyboards love a trailing space.
  • Ambiguous characters. The bundle's default charset 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.
  • Accessibility is a real blocker, not a note. Image captcha with no audio alternative fails WCAG 2.x on any public flow. This package ships no audio path. If you have ADA/EAA exposure, plan an alternative: an audio challenge you generate yourself from the same phrase, or a non-captcha control (email confirmation, rate limiting + honeypot) on the accessible route.
  • It does not stop targeted abuse. Static distorted text is defeatable by modern vision models. Position it as spam friction for low-to-moderate bot pressure, and keep 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.
  • Multiple captchas per page breaks on a shared key. Two forms, one session key, second render overwrites the first phrase. Pass a distinct {key} per form (the bundle's session_key option) and namespace the session entry accordingly.
  • Debugging checklist when the image is blank or 500s: confirm 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.
  • Version pinning. The bundle's v3.0.0 line requires PHP ≥ 8.0.2; older PHP maps to 2.2.x. Irrelevant to you directly, but it tells you the underlying gregwar/captcha is actively maintained and PHP 8-clean — a decent signal for a library you're adopting without the bundle wrapper.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core