laminas/laminas-captcha
Laminas Captcha generates and validates CAPTCHAs for PHP applications, with adapters for image, Figlet, and ReCaptcha challenges. It integrates with laminas-validator and forms to help protect sites from automated submissions.
laminas/laminas-captcha is a framework-agnostic adapter layer over three CAPTCHA implementations: Figlet (ASCII art), Image (GD-rendered PNG), and ReCaptcha (Google v2, delegated to laminas/laminas-recaptcha). There is no Laravel service provider and no auto-discovery — you wire everything yourself.
composer require laminas/laminas-captcha laminas/laminas-text
# only if you want the ReCaptcha adapter:
composer require laminas/laminas-recaptcha
laminas/laminas-session and laminas/laminas-stdlib arrive as hard dependencies; laminas/laminas-text is needed for Figlet/Image, and GD must be enabled for the Image adapter.
Where to look first: ignore the repo README diagram and read src/AbstractAdapter.php and src/AbstractWord.php. Everything you need to know (session persistence, generate(), render(), isValid()) lives in those two classes; the three adapters are thin subclasses. The Laminas\Captcha\Factory class is the only other file worth skimming.
First use case — replace a hand-rolled honeypot on a contact form with Figlet, purely to learn the lifecycle:
use Laminas\Captcha\Figlet;
$captcha = new Figlet([
'name' => 'captcha', // required, see Gotchas
'font' => resource_path('fonts/figlet/larry3d.flf'), // absolute path
]);
$captcha->generate(); // picks a random word + id, persists to session
$html = $captcha->render(); // "<pre>...</pre>" + hidden id input
Then validate on submit:
if (! $captcha->isValid($request->input('captcha'))) {
// $request->input('captcha') === ['id' => '...', 'input' => '...']
return back()->withErrors(['captcha' => $captcha->getMessages()]);
}
Do not skip straight to Image — the session plumbing is identical, but the Image adapter adds filesystem and public-URL concerns that will mask the real problem if the session bridge is wrong.
Don't instantiate adapters inline in controllers. Register one small factory that maps a config key to a configured adapter:
// config/captcha.php
return [
'default' => env('CAPTCHA_DRIVER', 'image'),
'drivers' => [
'image' => [
'class' => \Laminas\Captcha\Image::class,
'name' => 'captcha',
'imgDir' => storage_path('app/public/captcha'),
'imgUrl' => '/storage/captcha',
'font' => resource_path('fonts/DejaVuSans.ttf'),
'width' => 220,
'height' => 80,
'wordLen' => 6,
'fontSize' => 28,
'expiration'=> 300,
],
'figlet' => [ /* ... */ ],
'recaptcha' => [
'class' => \Laminas\Captcha\ReCaptcha::class,
'name' => 'g-recaptcha-response',
'pubkey' => env('RECAPTCHA_SITE_KEY'),
'privkey' => env('RECAPTCHA_SECRET'),
],
],
];
// app/Services/CaptchaManager.php
public function make(?string $driver = null): AbstractAdapter
{
$cfg = config('captcha.drivers.' . ($driver ?? config('captcha.default')));
return \Laminas\Captcha\Factory::factory($cfg); // handles 'class' key
}
Factory::factory() will pick the adapter from the class key (or the short name image/figlet/recaptcha) and pass the rest as options.
The adapter is cheap to construct and its only state is (a) the id/word pair and (b) the session entry. So the idiomatic Laravel split is:
// GET /contact
public function create(CaptchaManager $captchas)
{
$captcha = $captchas->make();
$captcha->generate();
return view('contact', ['captcha' => $captcha->render()]);
}
// POST /contact
public function store(ContactRequest $request, CaptchaManager $captchas)
{
// A *fresh* instance with the same session container; the word is
// rehydrated from the session by id.
$captcha = $captchas->make();
$captcha->isValid($request->input('captcha')); // sets its own messages
}
Never call generate() on POST — it overwrites the session word and every submission fails with "badCaptcha".
Wrap isValid() in a ValidationRule so it composes with FormRequests:
class ValidCaptcha implements ValidationRule
{
public function __construct(private string $driver) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$captcha = app(CaptchaManager::class)->make($this->driver);
$payload = is_array($value) ? $value : ['id' => request('captcha.id'), 'input' => $value];
if (! $captcha->isValid($payload)) {
$fail(collect($captcha->getMessages())->first() ?: 'Invalid CAPTCHA.');
}
}
}
$request->validate([
'captcha.input' => ['required', new ValidCaptcha('image')],
]);
render() returns HTML you did not author, so emit it raw and supply the text input yourself:
{!! $captcha !!}
<input type="text" name="captcha[input]" autocomplete="off" required>
If you want to control markup entirely, skip render() and build the <img>/hidden input by hand using $captcha->getId() and $captcha->getImgUrl() . $captcha->getId() . '.png'.
setWord() before generate() is respected, which is the only sane way to test the flow:
$captcha = new Figlet(['font' => ..., 'name' => 'captcha']);
$captcha->setWord('ABC123')->generate();
$this->post('/contact', ['captcha' => ['id' => $captcha->getId(), 'input' => 'ABC123']])
->assertSessionHasNoErrors();
For ReCaptcha, use Google's published test keys (always-pass / always-fail) and fake the HTTP client rather than hitting Google in CI.
name explicitly. Standalone render() uses $this->getName() to build the hidden input. Leave it null and you get name="[id]" plus an unusable POST shape. This is the single most common "it renders but never validates" bug.AbstractWord persists the word in a Laminas\Session\Container (default namespace Laminas\Captcha\...) backed by $_SESSION. Laravel's session middleware does not call PHP's native session_start(), so in a Laravel app you must either (a) start a Laminas SessionManager with a distinct cookie name during boot, or (b) implement Laminas\Session\Storage\StorageInterface delegating to Laravel's Store and inject it via setSession(new Container('captcha', $manager)). Option (a) is faster to ship; option (b) is what you want if you care about cookie collisions or running behind an unusual session driver.isValid() takes an array, not a string. For Figlet/Image the contract is ['id' => ..., 'input' => ...]. Passing the raw string produces missingValue, not badCaptcha, which sends you chasing the wrong bug.imgDir is a filesystem path, imgUrl a URL path — they are unrelated options and a mismatch yields broken images with no error. On Laravel Cloud/Forge ephemeral filesystems, put imgDir on a persistent disk. Old PNGs are only cleaned by a probabilistic gcFreq sweep during generate(); add a scheduled captcha:prune job for anything real.NoFontProvidedException at generate()/render() time, not at construction.expiration defaults bite. The word expires on a timer (default 300s). Users who leave a tab open get a generic failure. Surface a distinct "refresh the page" message rather than "invalid code".$_SERVER['REMOTE_ADDR'] for remoteip verification. Behind a load balancer or Cloudflare this may be an internal IP and Google will reject. Set the remote IP on the underlying Laminas\ReCaptcha\ReCaptcha service before validating, or trust-proxy correctly.Laminas\Captcha\AdapterInterface.getMessages() is not Laravel-shaped. It returns a [key => message] map (missingValue, badCaptcha, errNotSame). Override per-adapter with the message option or setMessage(), and translate inside your rule — Laravel's lang/*/validation.php won't see them.AbstractAdapter::isValid() and render() are the only methods your rule/Blade layer touches. Subclassing is the cleanest escape hatch — e.g. a LaravelSessionAdapter that overrides getSession() to return a Laravel-backed container, or an HCaptcha adapter overriding isValid()/render(). The base classes are small and stable enough that this is low-risk.CaptchaManager, and don't let adapter-specific types leak into controllers — swapping to a Laravel-native solution later should be a one-file change.How can I help you explore Laravel packages today?