innocead/captcha-bundle
innocead/captcha-bundle is a Symfony bundle that adds CAPTCHA protection to forms, providing configurable challenge rendering, server-side validation, and straightforward integration for PHP applications seeking lightweight spam prevention.
Confirm the mismatch before you write a line of code. Despite the "PHP package" framing, innocead/captcha-bundle is a Symfony bundle targeting Symfony 2.1+ — no service provider, no config/*.php publish, no Blade directive, no facade. There is no composer require path that yields a working captcha inside a Laravel app. Your first task is not integration, it is a go/no-go: decide whether you are adopting (not possible), porting/harvesting the algorithm, or replacing it with a Laravel-native package or a hosted provider (Turnstile / hCaptcha / reCAPTCHA). For most teams the answer is replace; read the repo as a reference implementation.
Where to look first, in this order:
composer.json — check the require block for php, symfony/*, and the image-generation library. If it says php: >=5.3 with no PSR-4 autoload, stop; you are reading, not running.InnoceadCaptchaBundle.php — the bundle class. This tells you exactly which Symfony kernel hooks exist and how much of the framework it assumes.DependencyInjection/Configuration.php + Extension.php — this is the real spec. The config tree is the closest thing to documentation for what's configurable (length, charset, case sensitivity, provider, template).Resources/config/services.yml — the service graph. This is your port checklist: which services you need to recreate in a Laravel provider.Resources/views/*.twig — the render surface you will translate to Blade.Minimal port shape. Extract the algorithm and re-express it behind a Laravel-native seam:
// app/Contracts/CaptchaContract.php
interface CaptchaContract
{
public function challenge(): array; // ['id' => ..., 'payload' => ...]
public function validate(?string $token, ?string $challengeId): bool;
}
Bind it in a service provider, publish config, add one validation rule and one Blade component. That is roughly a day of work and gives you something you can patch, instrument, and swap.
First use case — registration form only. Don't wire the whole app. Pick the highest-value form (register, password/email), add the rule to the FormRequest, render the component in the Blade view, and ship with a metric for challenge_served / challenge_failed. Expand to other surfaces only after you've watched the false-positive rate for a week.
If you genuinely are on legacy Symfony 2.1–2.8: register the bundle in AppKernel, move the config into app/config/config.yml under innocead_captcha:, load the form type in your form builder, and render via the provided Twig block. Do not attempt this in a Laravel app "just to see" — the failure mode is a silently empty session read, not an exception.
The four-layer port (recommended pattern). Keep the layers separate so any one can be swapped:
// 1. Provider — algorithm, no framework knowledge
final class ImageCaptchaProvider implements CaptchaContract
{
public function challenge(): array
{
$code = Str::random($this->config['length']); // from the bundle's charset logic
$id = Str::uuid()->toString();
Cache::put("captcha:{$id}", hash_hmac('sha256', $code, config('app key')), now()->addMinutes(5));
return ['id' => $id, 'payload' => $this->render($code)];
}
}
// 2. Rule — the day-to-day call site
final class CaptchaRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$id = request()->input('captcha_id');
if (! app(CaptchaContract::class)->validate($value, $id)) {
$fail('captcha.invalid')->translate();
}
}
}
Consume-on-use. Validate and delete the cache key in the same operation. The bundle's original session semantics effectively gave you one-shot challenges; a cache-backed port loses that unless you make it explicit. Use Cache::pull() rather than get() + forget() so two concurrent requests can't both succeed on one token.
Per-form friction flags. Adopt the bundle's configurable-settings idea but express it in Laravel config so product can dial friction without a deploy:
// config/captcha.php
'driver' => env('CAPTCHA_DRIVER', 'turnstile'),
'surfaces' => [
'register' => ['enabled' => true, 'on_error' => 'fail_closed'],
'contact' => ['enabled' => true, 'on_error' => 'fail_open'],
'newsletter' => ['enabled' => false],
],
Blade component over a directive. A directive gives you no props and no slot. Mirror the bundle's Twig template override concept with an anonymous component:
<x-captcha surface="register" />
{{-- renders script tag + hidden captcha_id, or the image payload --}}
Provider-swap seam. If you ever want to move from self-hosted image to Turnstile, the CaptchaContract should absorb that. Hosted providers are stateless from your side: challenge() returns the widget config, validate() calls the siteverify endpoint with a timeout. Keep the timeout and the fail-open/fail-closed decision in the config, not in the provider — it's a product policy, not an implementation detail.
Validation-rule composition. Don't hard-block on captcha alone. Pair it with a honeypot field and a submit-timing check on low-value forms (cheap, no third-party script, no accessibility cost) and reserve the real captcha for surfaces where abuse actually costs money.
Test both directions explicitly. An anti-abuse control is only tested when you assert the failure path:
it('rejects a reused challenge token', function () { /* ... */ });
it('fails closed when the generator throws', function () { /* ... */ });
it('fails open on contact form when provider is down', function () { /* ... */ });
Instrument from day one. The bundle has no hooks. Add them in your port: increment captcha.served, captcha.passed, captcha.failed, captcha.errored tagged by surface. Without these you cannot distinguish "no bots" from "captcha is silently broken."
Symfony-side pattern (for legacy 2.1 apps). Use the form type + validator constraint the bundle ships: add the field to the form builder, let the constraint validate on submit, override the Twig template via the bundle's template config key rather than editing vendor files, and inject the service through the DI config — never instantiate the generator directly.
Laravel session ≠ Symfony session. The single most common porting bug. A naive copy of the validator will read session()->get('captcha') while the generator wrote to a different key or a different store, and every challenge fails. Verify by asserting a full generate→validate round-trip in a feature test before touching a real form.
Assume PHP 8 incompatible. A Symfony 2.1-era codebase is PHP 5.3-era: array() syntax, no type declarations, possibly functions removed in PHP 8. Verify composer.json constraints and autoload strategy before estimating effort. If it's PHP 5.3-only, this is a manual read, not a code reuse.
Fail-open vs fail-closed is a product decision, not a bug. The bundle has no opinion, which means you inherit silence. The two failure modes are opposite and both bad: fail-open leaves the form unprotected with no alert; fail-closed turns a generator error into a total conversion outage on every guarded form. Write the policy per surface in config and log which branch fired.
Challenge state must not live in per-node session files. Port it to Redis/Cache or validation breaks the moment you run more than one instance — and it breaks intermittently, which is the worst kind of bug to chase. TTL should be short (5 minutes) and the token single-use.
Challenge generation is a CPU-bound unauthenticated endpoint. An image captcha that renders per request is a cheap amplification vector. Rate-limit the challenge route per IP, cache issued payloads for their TTL rather than regenerating on re-render, and never let the render happen behind an expensive middleware stack.
Never store the answer in plaintext. The original design put the code in session; if you port to a cache you must hash it (hash_hmac) or the cache becomes a captcha oracle. Compare with hash_equals.
Accessibility is not optional in most jurisdictions. The bundle's default image challenge is an accessibility hazard. Whatever you port, provide an audio/alternative path or pick a hosted provider with documented WCAG guidance. Do not "add accessibility later" — it changes the interface.
The Twig templates are not reusable. Don't try to shoehorn them into Blade via a converter. Rewrite the markup; keep the CSS class names only if you have existing styling to match.
Extension points that actually matter. In the bundle these are: the Configuration tree (what's tunable), the validator constraint (where validation plugs in), and the Twig template override key (where presentation plugs in). In your port, the equivalents are config/captcha.php, the CaptchaRule, and the Blade component — keep those three seams clean and you can swap providers without touching call sites.
Debugging checklist when every submission fails: (1) is the challenge key being written and read from the same store; (2) is the token being consumed by a prior request (double-submit / double-render); (3) is the challenge TTL shorter than the form session; (4) is the siteverify timeout too aggressive for the provider; (5) is the hidden captcha_id actually reaching the server (check the request payload, not the rendered HTML).
Own the fork or don't fork it. Seven stars, zero dependents, single maintainer. If you copy code in, add a header comment with the upstream URL and the date, keep it behind CaptchaContract, and put it on the same upgrade-review list as your other internal packages. An unowned captcha degrades silently — the first symptom is a spam spike, discovered months later.
How can I help you explore Laravel packages today?