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

Zend Captcha Laravel Package

zendframework/zend-captcha

The zend-captcha component provides CAPTCHA generation and validation for Zend Framework and PHP applications. It includes image, word, and Figlet adapters, configurable options, and form integration helpers for adding human-verification challenges to user input.

View on GitHub
Deep Wiki
Context7

Getting Started

Read this first: the package is archived. zendframework/zend-captcha has had no release since June 2019 and the Zend Framework project was retired in favour of Laminas. The API is identical, so install the maintained fork instead:

composer require laminas/laminas-captcha

If you are contractually stuck on the zendframework/* name, composer require zendframework/zend-captcha still resolves from Packagist, but you get zero security fixes. The only change when migrating is the namespace: Zend\Captcha\* → Laminas\Captcha\*.

Where to look first (in this order):

  1. AdapterInterface — the whole contract is five methods: generate(), isValid($value), getId(), getInputName(), getName(). Everything else is adapter detail.
  2. AbstractAdapter / AbstractWord — session storage, timeout, ID generation, and the captcha[id] / captcha[input] naming convention that trips everyone up.
  3. Factory::factory(['class' => 'Image', ...]) — the config-driven constructor, and the same option keys zend-form uses.
  4. The concrete adapters: Dumb, Figlet, Image, ReCaptcha. Pick by dependency weight, not by feature list.

First use case — a self-hosted image challenge on a public form. The Dumb adapter first, because it has no GD, no font, and no filesystem requirements, so you can prove the round-trip before adding pixels:

use Laminas\Captcha\Dumb;

$captcha = new Dumb(['word' => 'abcde', 'timeout' => 300]);
$id = $captcha->generate();   // returns the captcha ID; word is stored in session

// render
echo '<input type="hidden" name="' . $captcha->getName() . '[id]" value="' . $id . '">';
echo '<input type="text" name="' . $captcha->getInputName() . '">';

// later, on POST
$captcha->setId($_POST[$captcha->getName()]['id']);
$captcha->isValid($_POST[$captcha->getName()]['input']); // bool

Once that works, swap Dumb for Image and deal with font, imgDir, and GD. Reach for ReCaptcha only if you have already decided a hosted third-party challenge is acceptable — at that point you are probably better off with a Laravel-native reCAPTCHA/hCaptcha package anyway.

Implementation Patterns

Adapter selection as config, not code. Drive the adapter from a config file and let Factory build it. Every adapter is constructed from the same flat options array, so this scales cleanly:

// config/captcha.php
return [
    'driver'  => env('CAPTCHA_DRIVER', 'image'),
    'options' => [
        'timeout' => 300,
        'image'   => [
            'class'   => 'Image',
            'font'    => resource_path('fonts/captcha.ttf'),
            'imgDir'  => storage_path('app/captcha'),
            'width'   => 200,
            'height'  => 70,
            'dotNoiseLevel'  => 20,
            'lineNoiseLevel' => 3,
        ],
    ],
];
$captcha = \Laminas\Captcha\Factory::factory(config('captcha.options.' . config('captcha.driver')));

The class key is mapped to the adapter FQCN by the factory; option keys are normalised snake_case-or-camelCase through AbstractOptions, so dot_noise_level and dotNoiseLevel both hit setDotNoiseLevel().

The two-request workflow is mandatory for every word-based adapter. The generated word lives in a Zend session container (default name Zend_Captcha), keyed by captcha ID. Your flow is always:

  1. GET — build adapter, generate(), stash the returned ID in a hidden field, render the challenge.
  2. POST — rebuild an adapter, setId($postedId), then isValid($postedInput).

Image additionally writes a file (<imgDir>/<id>.png by default) during generate(). Serve it with a route rather than exposing storage/:

Route::get('/captcha/{id}', function (string $id) {
    $path = storage_path("app/captcha/{$id}.png");
    abort_unless(is_file($path), 404);

    return response()->file($path)->header('Cache-Control', 'no-store, no-cache');
});

Delete the file after a successful validation, or prune on a schedule — the adapter does not clean up after itself.

Laravel validation rule wrapper. The cleanest integration is a Rule object that owns the ID lookup so controllers never touch the adapter:

final class CaptchaRule implements ValidationRule
{
    public function __construct(private string $id) {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $captcha = \Laminas\Captcha\Factory::factory(config('captcha.options.' . config('captcha.driver')));
        $captcha->setId($this->id);

        if (!$captcha->isValid($value)) {
            $fail('The captcha is invalid or has expired.');
        }
    }
}
$request->validate([
    'captcha.id'    => ['required', 'string'],
    'captcha.input' => ['required', 'string', new CaptchaRule($request->input('captcha.id'))],
]);

Session isolation. AbstractAdapter::setSession() accepts a Laminas\Session\Container, and this is the escape hatch you want inside a Laravel app. Give each captcha a distinct container name so two forms on one page, or two browser tabs, don't clobber each other:

$captcha->setSession(new \Laminas\Session\Container('captcha_contact'));

Alternatively, skip the Zend session entirely for Dumb/Image: after generate(), read the word via getWord() (available on AbstractWord but not part of AdapterInterface) and put it in Laravel's own session or cache. That gives you a Laravel-native, testable flow at the cost of one non-interface method call.

Testing. Use Dumb everywhere in tests, and setWord() to make the answer deterministic. Never let a test hit ReCaptcha — it performs a live outbound HTTP call and will fail in CI. If you must exercise ReCaptcha, it accepts a Laminas\Http\Client instance you can substitute with a mock.

Laravel view component. A Blade component that renders <img src="/captcha/{{ $id }}">, the hidden captcha[id], and the text input is the right abstraction boundary; keep getName()-derived field names inside it so the captcha[id] contract never leaks into your form markup.

Gotchas and Tips

The ID is not optional, and forgetting it is the #1 failure. isValid() without a matching setId() looks up nothing in session and returns false (or throws a "captcha id … not found" style exception). Always round-trip captcha[id]. If you use zend-form/laminas-form with the Captcha element and the formCaptcha helper, this is done for you — hand-rolled forms must do it manually.

Field names are namespaced by default. getName() returns captcha, getInputName() returns captcha[input], and the hidden ID field is captcha[id]. Your validation keys must match (captcha.input, not captcha). Use setName() if you need two captchas in one form.

Image fails loudly at construction, quietly in production. font must be an absolute path to a readable TTF and imgDir must exist and be writable by the PHP user. There is no bundled font — unlike ZF1 — so a missing font option throws from Zend\Captcha\Exception\* (missing font / unreadable image dir). Add both to your deploy checklist; these break only on the target environment. Also confirm GD is compiled with FreeType (gd_info()['FreeType Support']).

No garbage collection. Every Image::generate() writes a PNG that stays until you delete it. Under any real traffic this fills the disk. Either unlink() in the validation rule after success, or add a scheduled storage/app/captcha prune.

Zend Session vs Laravel Session. The adapter's default storage uses Laminas\Session\SessionManager, which sits on PHP's native session. If your Laravel session driver is redis, database, or cookie, the two stores are unrelated — Zend will silently use $_SESSION and behave inconsistently behind load balancers. Either inject an explicit Container, or bypass it with getWord() as described above. Do not assume "it worked locally" means it works in a multi-node deployment.

Expiry is a timestamp, not a sliding window. timeout defaults to 300 seconds and is checked from generation time. A user who takes six minutes to fill the form gets a failure with no distinguishing message. Surface "expired, please retry" yourself rather than showing a bare "invalid captcha".

Don't register the adapter as a singleton. A Laravel container binding in singleton() scope will reuse the same challenge across requests. Bind as a factory, or just new it — construction is cheap; only Image::generate() does real work.

Accessibility and UX debt is on you. The Image adapter is text-in-an-image with noise; it has no audio alternative. Dumb deliberately relies on CSS obfuscation and is trivially defeated by any bot that reads the HTML. Neither is a serious anti-automation control in 2024 — treat them as spam friction, and pair with rate limiting (RateLimiter::for('forms', ...)), honeypots, and Laravel's built-in CSRF rather than as the sole defence.

Extension points. To customise rendering or storage, extend AbstractWord and implement generate() plus getHelperName(); to add a new challenge type, implement AdapterInterface directly. Subclassing Image and overriding the file-writing step is the standard way to store challenges as base64 in the session instead of on disk.

Migration is a one-line find/replace. When you are ready to leave the archived package: composer require laminas/laminas-captcha, then Zend\Captcha → Laminas\Captcha across the codebase. Do it before you build much on top of it — the API is stable but the zendframework/* packages will never receive a CVE fix.

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.
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics
robbiep/afterthedeadline
frankkessler/incontact-laravel-oauth2-rest
jlxh/laravel-userlog
davidmgilo/timetables
qylinfly/role-permission
reflect-digital/conduit
takashato/laravel-vietqr
aitor24/laralang
kraenzle-ritter/entity-relations
dindigital/din-image
pomirleanu/image-colors
sawmainek/laravel-api-generator
delatbabel/jsonresponses
mouhamedfd/permissions-generator
eduplus/qr