Skip to content
Laravel package

Wiz Captcha

A self-hosted image CAPTCHA for Laravel with text, number and math challenges, hashed answers and attempt limits.

composer require devwizardhq/wiz-captcha
View on GitHub
On this page

Overview

Wiz Captcha is a small, self-hosted CAPTCHA package for Laravel. It draws an image challenge on your own server with PHP’s GD extension, stores only a hash of the answer in your cache, and validates what the visitor types with a standard Laravel validation rule. There is no third-party service, script or tracking involved.

It supports classic form posts, where the image is served from a route and tied to the visitor’s session, and API clients, where you request a challenge as JSON and send the key back with the answer. The package is compact: one facade, three helpers, two validation rules and three challenge presets. It is built by the DevWizard core team and is MIT licensed.

Features

  • Three challenge types: random text, numbers only, and simple math (+ and -).
  • Answers are hashed before storage and never sent to the browser.
  • Challenges expire after a configurable time, and a challenge is deleted after too many wrong attempts.
  • Image rendering with GD, with configurable size, noise, lines and background colour.
  • Validation through CaptchaRule, the wiz_captcha string rule, or CaptchaApiRule for API flows.
  • A JSON endpoint that returns the image as a base64 data URI.
  • Built-in route throttling, tunable through one environment variable.
  • Cache-backed storage, which uses Redis automatically when your cache does.
  • Swappable internals through the CaptchaStore, ChallengeGenerator and ImageRenderer contracts.

Requirements

  • PHP 8.2 or newer with the gd extension.
  • Laravel 11, 12 or 13.
  • A cache store (the default store works).

Installation

Terminal
composer require devwizardhq/wiz-captcha

Publish the configuration file if you want to change presets or limits:

Terminal
php artisan vendor:publish --tag="wiz-captcha-config"

Configuration

The published file is config/wiz-captcha.php.

Key Environment variable Default Purpose
enabled WIZ_CAPTCHA_ENABLED true When false, verification always passes
default none default Preset used when none is named
cache_store WIZ_CAPTCHA_CACHE_STORE app default Cache store for challenges
expire WIZ_CAPTCHA_EXPIRE 300 Challenge lifetime in seconds
max_attempts WIZ_CAPTCHA_MAX_ATTEMPTS 5 Wrong answers before the challenge is discarded
case_sensitive WIZ_CAPTCHA_CASE_SENSITIVE false Whether answers are case sensitive
routes.enabled none true Register the image and API routes
routes.prefix none captcha URL prefix
routes.throttle WIZ_CAPTCHA_ROUTE_THROTTLE 60,1 Requests per minute per client

Three presets ship under presets: default (text, 6 characters), number (5 digits) and math (operands from 1 to 20 with + and -). Each sets width, height, lines, noise and background, and you can add your own. Set WIZ_CAPTCHA_ROUTE_THROTTLE to a bare 60,1 or a full middleware string such as throttle:120,1.

Usage

Show the image in a form and validate the answer in the controller:

Blade
<form method="POST" action="/contact">
    @csrf

    {!! wiz_captcha_img('default', ['id' => 'captcha-image']) !!}

    <input type="text" name="captcha" required autocomplete="off">

    @error('captcha')
        <div>{{ $message }}</div>
    @enderror

    <button type="submit">Send</button>
</form>
PHP
use DevWizardHQ\Captcha\Rules\CaptchaRule;

$request->validate([
    'captcha' => ['required', new CaptchaRule],
]);

The image route stores the challenge key in the visitor’s session, so the rule knows which challenge to check. To let visitors refresh the image, reload its source with a changing query string:

HTML
<script>
function refreshCaptcha() {
    const img = document.getElementById('captcha-image');
    img.src = "{{ route('wiz-captcha.image') }}?" + Date.now();
}
</script>

API Reference

Helpers

Helper Returns
wiz_captcha_src($preset = 'default') URL of the image route for a preset
wiz_captcha_img($preset = 'default', $attributes = []) An <img> tag as HtmlString
wiz_captcha_api($preset = 'default') Array with key, image (base64 data URI) and expires_in

Validation rules

  • new CaptchaRule, or the string rule wiz_captcha: checks the answer against the latest challenge issued to the session.
  • new CaptchaApiRule($key), or the string rule wiz_captcha_api: checks the answer against the challenge with that key.

Captcha class and facade

DevWizardHQ\Captcha\Captcha (also available as DevWizardHQ\Captcha\Facades\Captcha) has three public methods: create($preset) returns key, image, mime and expires_in; createApi($preset) returns the same with a base64 image; verify($key, $input) returns a boolean.

Routes

Under the configured prefix (default /captcha):

Route Name Response
GET /captcha/{preset?} wiz-captcha.image PNG image with no-cache headers
GET /captcha/api/{preset?} wiz-captcha.api JSON challenge

Contracts

CaptchaStore (put, get, forget, incrementAttempts), ChallengeGenerator (generate) and ImageRenderer (render). The package binds CacheStore and GdRenderer by default.

Examples

Use the math preset for a friendlier challenge:

Blade
{!! wiz_captcha_img('math', ['id' => 'captcha-image']) !!}

Serve a challenge to a single-page app or mobile client:

PHP
Route::get('/auth/captcha', fn () => response()->json(wiz_captcha_api('math')));

Validate the answer in the matching API endpoint:

PHP
use DevWizardHQ\Captcha\Rules\CaptchaApiRule;

$request->validate([
    'captcha_key' => ['required', 'string'],
    'captcha' => ['required', new CaptchaApiRule($request->captcha_key)],
]);

Switch off the CAPTCHA in a local environment:

.env
WIZ_CAPTCHA_ENABLED=false

FAQ & Troubleshooting

The image is broken or blank. The gd PHP extension must be installed and enabled. Check with php -m | grep gd.

Validation always fails. Form flows rely on the session, so the image route and the form must share the same session and cookies. For stateless clients use wiz_captcha_api() and CaptchaApiRule instead.

Users get “429 Too Many Requests” when refreshing. The routes are throttled. Raise the limit with WIZ_CAPTCHA_ROUTE_THROTTLE.

A correct answer was rejected. Challenges expire after WIZ_CAPTCHA_EXPIRE seconds and are discarded after WIZ_CAPTCHA_MAX_ATTEMPTS wrong tries. Matching is case insensitive unless WIZ_CAPTCHA_CASE_SENSITIVE is true.

Can I use my own storage or renderer? Yes. Bind your own implementation of CaptchaStore, ChallengeGenerator or ImageRenderer in a service provider.

Changelog & Support

Release notes are in the CHANGELOG and on the GitHub releases page. Report bugs in GitHub issues and security concerns through the repository’s security policy. Source: DevWizardHQ/wiz-captcha. For custom authentication or anti-abuse work, the DevWizard team takes project contracts and monthly retainers through the contact page.