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 Laravel Package

gregwar/captcha

gregwar/captcha is a lightweight PHP library for generating customizable CAPTCHA images with distortion, fonts, and backgrounds. Build, save, output, or inline them, then validate user input with testPhrase().

View on GitHub
Deep Wiki
Context7

Getting Started

Install and verify the runtime

composer require gregwar/captcha

Use ^2.1 on Laravel applications running PHP 8.2+. v2.x is the current line and includes cryptographically secure phrase generation (v2.1.0), strict typing, and package-specific exceptions (v2.0.0). If you are still on PHP 8.1 or older, you would be pinning v1.x — plan the PHP upgrade instead of building around an old tag.

Confirm GD with FreeType support is present in every environment that renders captchas, including CI and production containers:

php -m | grep -i gd

The package ships fonts and uses GD to render them; a GD build without FreeType will fail at image creation time, not at install time.

Where to look first

  1. The README API list — it is the entire public surface: build(), get(), save(), output(), inline(), getPhrase(), testPhrase(), plus effect and background setters.
  2. demo/form.php and demo/session.php in the repository — the canonical session-based issue/verify flow. It is plain PHP, but the lifecycle maps directly onto Laravel.
  3. Gregwar\Captcha\PhraseBuilder — this is how you control phrase length and character set. Most day-to-day customization happens here, not in CaptchaBuilder.

First use case: protect one public form

The smallest useful integration is a captcha endpoint that renders the image and stores the phrase in the session, plus a validation rule on the form submit.

routes/web.php:

use Gregwar\Captcha\CaptchaBuilder;

Route::get('/captcha/contact', function () {
    $builder = new CaptchaBuilder;
    $builder->build(160, 50);

    session()->put('captcha.contact', [
        'phrase' => $builder->getPhrase(),
        'expires_at' => now()->addMinutes(5)->timestamp,
    ]);

    return response($builder->get(), 200, [
        'Content-Type' => 'image/jpeg',
        'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
        'Pragma' => 'no-cache',
    ]);
})->middleware('throttle:30,1')->name('captcha.contact');

Blade:

<img src="{{ route('captcha.contact') }}?v={{ now()->timestamp }}"
     alt="Enter the characters shown" width="160" height="50">
<input type="text" name="captcha" autocomplete="off" required>

Form request rule:

public function rules(): array
{
    return [
        'captcha' => ['required', 'string', new ValidCaptcha('contact')],
    ];
}

Do not use $builder->output() inside Laravel. It calls header() directly and bypasses the response stack; use get() and return a Laravel response with explicit headers.

Implementation Patterns

Keep issuers and verifiers as separate concerns

A small service or factory keeps profile settings out of controllers and makes the per-form configuration consistent:

namespace App\Support;

use Gregwar\Captcha\CaptchaBuilder;
use Gregwar\Captcha\PhraseBuilder;

class CaptchaFactory
{
    public function make(string $profile = 'default'): CaptchaBuilder
    {
        $config = config("captcha.profiles.{$profile}");

        $phraseBuilder = new PhraseBuilder(
            $config['length'],
            $config['charset']
        );

        $builder = new CaptchaBuilder(null, $phraseBuilder);
        $builder->setImageType($config['type'] ?? 'jpeg');
        $builder->setDistortion($config['distortion'] ?? true);
        $builder->setInterpolation($config['interpolation'] ?? true);
        $builder->setMaxBehindLines($config['behind_lines'] ?? 10);
        $builder->setMaxFrontLines($config['front_lines'] ?? 5);

        return $builder;
    }
}

config/captcha.php:

return [
    'ttl' => 300,

    'profiles' => [
        'contact' => [
            'length' => 5,
            'charset' => 'abcdefghjkmnpqrstuvwxyz23456789',
            'width' => 160,
            'height' => 50,
            'type' => 'jpeg',
        ],
        'register' => [
            'length' => 4,
            'charset' => '0123456789',
            'width' => 140,
            'height' => 45,
            'type' => 'png',
        ],
    ],
];

Issue endpoint that is hard to cache and hard to farm

Browsers will cache image URLs aggressively, and users will double-submit forms. Both cause "captcha expired/incorrect" complaints. Cache-bust the URL and throttle the image route:

Route::get('/captcha/{profile}', CaptchaController::class)
    ->whereIn('profile', ['contact', 'register'])
    ->middleware('throttle:captcha')
    ->name('captcha');
RateLimiter::for('captcha', function (Request $request) {
    return Limit::perMinute(20)->by($request->ip());
});

The image endpoint is stateless from the client's point of view, so it is cheap to request repeatedly. Throttling both the image issuance and the form submission matters more than making the image itself prettier.

Validation rule with one-time use and expiry

The phrase must be treated as a secret with a short lifecycle: issue, verify once, discard. A custom rule is the cleanest place to enforce this.

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class ValidCaptcha implements ValidationRule
{
    public function __construct(private string $profile = 'default') {}

    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        $payload = session()->pull("captcha.{$this->profile}");

        if (! is_array($payload)
            || ! isset($payload['phrase'], $payload['expires_at'])
            || now()->timestamp > $payload['expires_at']) {
            $fail('The CAPTCHA has expired. Please request a new one.');
            return;
        }

        if (! hash_equals($payload['phrase'], (string) $value)) {
            $fail('The CAPTCHA is incorrect.');
        }
    }
}

pull() rather than get() is deliberate: it removes the phrase whether the attempt succeeds or fails, preventing replay. After a failed attempt, re-render a fresh captcha in the redirect response.

Handling multiple forms and multiple tabs

Session keys must be profile-scoped. A single captcha_phrase key breaks as soon as two forms, or two tabs, are open:

session()->put("captcha.{$profile}", [
    'phrase' => $builder->getPhrase(),
    'expires_at' => now()->addSeconds(config('captcha.ttl'))->timestamp,
]);

If you need multiple concurrent challenges for the same profile, add a client-supplied or server-issued nonce to the key:

session()->put("captcha.{$profile}.{$nonce}", [...]);

Render the nonce as a hidden form field and read it in the rule.

Inline images for forms that must not make an extra request

inline() returns a data URI, so it embeds the image directly into HTML:

$builder = app(CaptchaFactory::class)->make('contact');
$builder->build(160, 50);

session()->put('captcha.contact', [
    'phrase' => $builder->getPhrase(),
    'expires_at' => now()->addMinutes(5)->timestamp,
]);

return view('contact', ['captcha' => $builder->inline()]);
<img src="{{ $captcha }}" alt="Enter the characters shown">

This avoids a separate image request and browser caching issues entirely, at the cost of a larger HTML payload and no reuse across forms. It works well for a single low-traffic contact form; it is a poor fit for a high-traffic page because every render generates a new phrase and a new base64 image.

Stateless/API pattern using the cache

api routes in Laravel do not have session middleware by default. For a mobile client or SPA, issue a random token and store the phrase server-side:

$token = Str::random(32);
$builder = app(CaptchaFactory::class)->make('api');
$builder->build();

Cache::put("captcha:{$token}", [
    'phrase' => $builder->getPhrase(),
    'expires_at' => now()->addMinutes(5)->timestamp,
], now()->addMinutes(5));

return response($builder->get(), 200, ['Content-Type' => 'image/jpeg']);

On submit:

$payload = Cache::pull("captcha:{$request->token}");

Validate with hash_equals, check expiry, and return a single error. Do not put the phrase in the token or in a signed URL; store it server-side.

Testing

Inject a known phrase to test the whole path without reading pixels or OCR:

it('rejects an incorrect captcha', function () {
    $this->withSession([
        'captcha.contact' => [
            'phrase' => 'abcde',
            'expires_at' => now()->addMinutes(5)->timestamp,
        ],
    ])->post('/contact', [
        'captcha' => 'wrong',
    ])->assertSessionHasErrors('captcha');
});

it('accepts and consumes the correct phrase', function () {
    $this->withSession([
        'captcha.contact' => [
            'phrase' => 'abcde',
            'expires_at' => now()->addMinutes(5)->timestamp,
        ],
    ])->post('/contact', [
        'captcha' => 'abcde',
    ])->assertSessionMissing('captcha.contact');
});

Also assert that the image endpoint returns the right Content-Type and a non-empty body, and that the route is throttled.

Testable phrase injection

You do not need to mock the package. new CaptchaBuilder('hello') produces a known phrase, which is useful for local demos and for asserting image generation in isolation:

$builder = new CaptchaBuilder('hello');
$builder->build(120, 40);
expect($builder->get())->not->toBeEmpty();
expect($builder->testPhrase('hello'))->toBeTrue();

Gotchas and Tips

There is no first-party Laravel integration

This is a framework-agnostic PHP library. No service provider, no publishable config, no Blade component, no validation rule. You own the glue. That is fine for one or two forms; if you need captchas across many forms, wrap the factory, rule, and image controller in a small internal package or module so you do not copy-paste session key handling.

output() is a trap in Laravel

output() sends Content-type: image/jpeg via header() and echoes the image. In a Laravel request this interferes with middleware, testing, and response decorators. Always use get() and return a response. Similarly, save() is for CLI/batch generation; do not write captcha images to disk per request just to serve them.

getPhrase() must be persisted before the response is sent

The phrase lives in memory. If you return the image without storing the phrase, the form can never validate. If you store it after calling get() or inline(), that is fine, but keep the order obvious in the controller. Never log the phrase and never expose it in the HTML.

Image type and transparency interact

setImageType() accepts jpeg, png, or gif. Call it before build()/inline() and set the matching response Content-Type. JPEG does not support alpha, so setBackgroundAlpha() will not produce visual transparency in a JPEG. If you need transparency, use PNG and serve image/png. If you use inline(), confirm the prefix matches the image type you set, otherwise some browsers will still render it but debugging becomes confusing.

Effects settings are order-sensitive and occasionally self-defeating

  • setBackgroundColor() disables many effects; do not use it to "clean up" a noisy image unless you intend to reduce CAPTCHA difficulty.
  • setBackgroundImages() is intended to be used with setIgnoreAllEffects(true). Mixing custom photographic backgrounds with distortion and lines often produces an unreadable image.
  • setDistortion(), setInterpolation(), setScatterEffect(), setMaxBehindLines(), and setMaxFrontLines() must be called before build().

Font paths must be absolute and readable by the PHP process

The default fonts are inside the package and work out of the box. If you pass a custom $font to build($width, $height, $font), use an absolute path (base_path('resources/fonts/x.ttf')), confirm the file exists in the deployed container, and confirm the GD build supports TrueType. A missing font is a runtime error, not a validation error.

Phrase comparison is exact — normalize user input deliberately

testPhrase() compares against the generated phrase. Do not assume case-insensitive or whitespace-insensitive matching. Either restrict your charset to a case your users will type consistently, or normalize both sides before comparing:

$phrase = strtolower(trim((string) $request->input('captcha')));

Use hash_equals() for your own comparison to keep timing-safe semantics, especially if you replace the built-in testPhrase() path with a custom rule.

Choose a charset that avoids transcription errors

The default phrase builder is configurable, and this is the single highest-value tuning knob for support tickets. For digit-only challenges, '23456789' is safer than '0123456789'. For alphanumeric, omit 0/O, 1/l/I. Length 4 is noticeably faster to solve than length 6; length 6 with a noisy background is a conversion killer on mobile.

Expire and consume phrases, always

The package gives you getPhrase() and testPhrase(); it does not give you a TTL, session binding, single-use enforcement, or invalidation after successful login. Implement all four. Pull the payload from the session before comparison, check expires_at, and regenerate the challenge on failure. On successful authentication or registration, call session()->regenerate() after consuming the captcha.

Multiple tabs and browser back buttons

A single session key per profile means the second tab overwrites the first tab's phrase. The user sees "incorrect captcha" on a form they typed correctly. Either accept this for low-stakes forms or key challenges by a nonce rendered in the form. Also set Cache-Control: no-store on the image route; otherwise the browser may show a stale image after the session phrase has already rotated.

buildAgainstOCR() is a build-time tool, not a request-time tool

It requires shell_exec to be enabled plus ImageMagick and ocrad installed. In most production PHP containers shell_exec is disabled or heavily restricted, and looping image generation until OCR fails is far too expensive for a web request. If you want OCR-resistant images, generate a pool in a queue/CLI command and serve from storage or cache. Also be honest about what it proves: it defeats ocrad, not a modern ML solver.

Rate limiting and the surrounding controls are not optional

The captcha is one layer. Pair it with:

  • throttle middleware on both the image route and the submit route.
  • A honeypot field or timestamp check to catch naive bots without spending image-rendering CPU.
  • Email/phone verification for high-value registration flows.
  • Server-side logging of captcha failures by IP/account to detect farming.

Without throttling, an attacker can request the image endpoint at will and burn CPU. Without a honeypot, they can skip the captcha path entirely for simple bots.

Accessibility is a product decision, not a package setting

The library only produces an image challenge. It has no audio alternative. If accessibility is a hard requirement, you must ship an alternative path — an audio challenge implemented separately, an allowlist, or a lower-friction verification flow. Do not treat "we added a captcha" as a complete accessibility story.

Session driver and cookie size

The phrase is small, so payload size is rarely a problem. But if you use the cookie session driver, remember the challenge state is client-held and encrypted; that is acceptable for an image captcha, but it means the phrase validation depends on the session cookie surviving the round trip. If your app rotates cookies aggressively or has multiple domains/subdomains, test the captcha flow across those boundaries. For api routes, use the token+cache pattern instead of trying to force session middleware onto stateless endpoints.

Pin the major version and test the upgrade

v2.0.0 raised the minimum PHP version to 8.2, added strict typing, and introduced package-specific exceptions; v2.1.0 changed phrase generation to secure randomness. Pin gregwar/captcha to ^2.1 in composer.json, include an image-generation smoke test in CI, and verify GD/FreeType in the CI image. If you are coming from v1.x, expect method signatures to be stricter and handle the package exceptions explicitly rather than relying on generic \Exception catches.

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.
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle