mulertech/captcha
MulerTech Captcha is a Laravel package for adding image-based captcha challenges to forms, with validation rules, customizable configuration, and simple Blade integration to help protect your application from bots and spam.
This is a Symfony bundle, not a Laravel package — there is no service provider, no config/*.php to publish, no Blade views, and no php artisan command. Before you composer require it in a Laravel app, decide which of these you are actually doing:
Minimal steps (Symfony path):
composer require <vendor>/math-captcha-bundle:^1.0
php -r "var_dump(extension_loaded('gd'), gd_info()['JPEG Support']);"
Requirements to verify first: PHP 8.4+, ext-gd with JPEG support, Symfony 6.4/7.x/8.x, Forms + Twig. Then add CaptchaType to any form — the form theme and /captcha/refresh route auto-register, so a working captcha is one field addition away.
First use case: drop it on a public contact/registration form that currently has only a honeypot. It is a spam speed bump, not a bot defence — pair it with rate limiting.
Where to look first in the source: CaptchaType (the compound field), the ValidCaptcha constraint + validator, the refresh controller/route, the session token store (TTL + 5-token cap), and the bundled Twig theme (that is where the JS refresh and CSP nonce handling live).
Standard Symfony form usage.
$builder->add('captcha', CaptchaType::class, [
'csp_nonce' => $nonce, // omit if CspNonceGenerator is auto-detected
])->add('captcha', ValidCaptcha::class); // or via constraints option
With error_bubbling off by default, the error lands on form.captcha.vars.errors — render it next to the image, not in a global error block.
Token lifecycle (the bit worth understanding). Each render mints a unique token, stores {token => answer, expires_at} server-side in the session, and puts only the token in a hidden field. Submit → validator looks up, compares, consumes. Consequences you should design for:
Client-side refresh pattern. On a failed submission you should not re-render the whole page just to get a new captcha image. The bundled theme does a fetch() to /captcha/refresh, gets JSON, and swaps the <img> src plus the hidden token — so the token in the DOM always matches the session state. If you write your own Blade/JS equivalent, replicate exactly that: image and hidden token must be replaced atomically, or you get a guaranteed mismatch on the next submit.
If you are porting to Laravel, the equivalent pieces are:
| Symfony bundle | Laravel equivalent |
|---|---|
| Session token store | session() or Cache::put("captcha:$token", $answer, 600) — cache is better, it survives session regeneration |
/captcha/refresh route |
Route in routes/web.php, GET, no CSRF, but must be same-origin so the session cookie is sent |
| Twig form theme | Blade partial + a small fetch script |
CaptchaType |
A custom validation rule + a <x-captcha /> component |
ValidCaptcha |
Rule::captcha() or a CaptchaRule object |
For the fetch call, set credentials: 'same-origin' explicitly — the default fetch behaviour with cookies is a classic source of "always expired" bugs when the refresh runs from a different subdomain or after a session regeneration on login.
CSP. The theme deliberately uses addEventListener rather than inline onclick, so it survives a strict script-src. Two integration options: let it auto-detect CspNonceGenerator, or pass csp_nonce per field. If you render forms through a cache layer or a fragment cache, the nonce will be stale and the script blocked — exclude any page containing the captcha from full-page caching.
i18n. French and English ship in Symfony Translation format. Laravel's __() will not pick these up; duplicate the four or five strings into lang/ if you port it. Overriding them in Symfony is a translation-file override under the bundle's domain, not a config option.
ext-gd can be present while imagejpeg() is unavailable (minimal Alpine/Docker images, some distro builds). The release notes mention ext-gd but the rendering path is JPEG specifically — verify gd_info()['JPEG Support'] in your container, not just locally./captcha/refresh is a fixed path with no config knob mentioned. If you already own that path, you get a silent route-order surprise. Check debug:router after install.error_bubbling is off by design; root-level getErrors() will look empty. Read the field's own errors.ValidCaptcha is a speed bump, not security. A low-quality JPEG with noise and lines stops trivially-scripted OCR and nothing more. Model-based solvers eat this for breakfast. Always combine with rate limiting, a honeypot, and a per-IP submission cap.csp_nonce field option, and the constraint messages. If you need to change TTL (10 min) or the 5-token cap, expect to override services or fork — neither appears configurable in v1.0.0.^1.0 and read the changelog before 1.1, since the constraint/field API surface is still young.symfony/form, symfony/http-foundation, symfony/translation, and a Twig pipeline inside a Blade app — that is a large surface area for one form field.How can I help you explore Laravel packages today?