tito10047/iconcaptcha-bundle

A self-hosted, customizable, easy-to-implement and user-friendly captcha for PHP.

Maintainers

Package info

github.com/tito10047/icon-captcha-bundle

Type:symfony-bundle

pkg:composer/tito10047/iconcaptcha-bundle

Transparency log

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-08-25 18:29 UTC

This package is auto-updated.

Last update: 2026-08-25 19:30:30 UTC


README

Symfony integration for IconCaptcha — a self-hosted, privacy-friendly captcha where the visitor picks the least-common icon out of five to eight. No third-party service, no tracking, no API key.

The underlying library talks to $_SESSION directly and ends its AJAX endpoint with exit(). This bundle replaces both with Symfony equivalents and adds a form type, a constraint, a Twig extension and asset wiring.

Requirements

PHP >= 8.2, with the gd extension (or imagick)
Symfony 6.4, 7.4 or 8.1
Session framework.session must be enabled — challenges and the CSRF token live there

Installation

composer require tito10047/iconcaptcha-bundle

Symfony Flex registers the bundle in config/bundles.php automatically. Without Flex, add it by hand:

// config/bundles.php
return [
    // ...
    Tito10047\IconcaptchaBundle\IconcaptchaBundle::class => ['all' => true],
];

Register the endpoint

The widget loads its challenge over AJAX, so the bundle's route has to be imported. There is no published Flex recipe yet, so this step is manual:

# config/routes/icon_captcha.yaml
icon_captcha:
    resource: '@IconcaptchaBundle/config/routes.php'
    type: php

This registers a single route, iconcaptcha_request, at POST /iconcaptcha/request. It only answers to requests carrying X-Requested-With: XMLHttpRequest and a valid token; anything else gets a 400.

Usage with a form

use Tito10047\IconcaptchaBundle\Type\IconCaptchaType;

$builder->add('captcha', IconCaptchaType::class);

That is the whole integration. The field is unmapped, carries the IconCaptcha constraint, and renders the widget, the CSRF token and the client init script from the bundle's form theme — so {{ form(form) }} or {{ form_row(form.captcha) }} is enough.

Field options

Option Type Default Meaning
theme 'light' | 'dark' the bundle's theme setting Widget colour scheme
include_assets bool false Emit <script>/<link> tags for the client assets next to this widget
client_options array [] Extra client options such as showCredits or locale

Turning the captcha off

A functional test suite cannot solve a challenge, so the protected form has to be submittable without one:

# config/packages/icon_captcha.yaml
when@test:
    icon_captcha:
        enable: false

With enable: false the field renders nothing at all and the constraint always passes. Both halves matter: rendering nothing while still validating would leave the form permanently unsubmittable. Nothing touches the session either, so a disabled captcha also works on a stateless route.

include_assets is off by default because most applications load the client once in their layout or their AssetMapper entrypoint; turning it on for several forms on one page would emit the script repeatedly.

client_options cannot override general.endpoint. It points at this bundle's route, and sending challenges anywhere else would put them somewhere the validator never reads back from.

Usage without a form

The Twig extension renders the same pieces by hand:

{{ iconcaptcha_style() }}
{{ iconcaptcha_script() }}

<form method="post">
    {{ iconcaptcha_widget('dark') }}
    {{ iconcaptcha_token() }}
    <button>Submit</button>
</form>

You then have to initialise the client yourself and validate the submission:

use Tito10047\IconcaptchaBundle\Validator\IconCaptcha;

$violations = $validator->validate(null, new IconCaptcha());

The constraint reads the widget's hidden fields straight off the current request, which is why the value passed to validate() is irrelevant.

Usage with UX Live Components

Works out of the box, provided the widget renders through the Stimulus controller — which it does automatically as soon as symfony/stimulus-bundle is installed. Enable the controller in your application:

// assets/controllers.json
{
    "controllers": {
        "@tito10047/iconcaptcha-bundle": {
            "iconcaptcha": { "enabled": true, "fetch": "eager" }
        }
    }
}

That is the whole integration; $builder->add('captcha', IconCaptchaType::class) is unchanged. Three things happen behind it, and they are worth knowing about because each one works around an assumption a Live Component breaks:

  • The widget carries data-live-ignore. A component re-renders on every model change, which without this would replace the widget mid-challenge and make the visitor start over on each keystroke elsewhere in the form. The field's own input deliberately does not carry it — that is how the solved payload travels into the model.
  • The solved challenge is copied into the field's value. A live request sends the component's model, never the DOM, so the widget's own hidden inputs (ic-rq, ic-wid, ic-cid) would otherwise never reach the server. The controller packs them into the field and dispatches a change event, which is the only thing the model binding watches.
  • A solved challenge stays valid for validation.completion_expiration. See below.

Reusing a solved challenge

ComponentWithFormTrait submits and validates the form on every re-render, not only on the action that saves it, while the library destroys a challenge the moment it accepts one. Left alone, the first model change after solving would spend the captcha and the real submit would come back "unsolved".

So a successful validation is remembered and accepted again for the rest of the completion window. The record lives in the visitor's own session, so this is not a way to skip the captcha — a fresh client still has to solve one. It does mean that within one session a single solve covers repeated submissions for completion_expiration seconds. Applications that do not use Live Components can have the stricter behaviour back:

icon_captcha:
    validation:
        reuse_solved: false

Client assets

The JS and CSS ship inside fabianwennink/iconcaptcha. The bundle never copies them, so they cannot drift from the installed library version.

With AssetMapper (recommended)

The bundle registers the library's client directory under the @fabianwennink/iconcaptcha namespace, matching its npm package name. Both files are declared in the bundle's assets/package.json, which Flex reads on install, so importmap.php gets them by itself:

// importmap.php — written by Flex
return [
    // ...
    '@fabianwennink/iconcaptcha/js/iconcaptcha.min.js' => [
        'path' => './vendor/fabianwennink/iconcaptcha/assets/client/js/iconcaptcha.min.js',
    ],
    '@fabianwennink/iconcaptcha/css/iconcaptcha.min.css' => [
        'path' => './vendor/fabianwennink/iconcaptcha/assets/client/css/iconcaptcha.min.css',
        'type' => 'css',
    ],
];

Add them by hand if they are missing — Flex turned off, or symfony/asset-mapper installed after the bundle. The path may then be the logical path the registered namespace provides, which reads better and survives a moved vendor directory:

'@fabianwennink/iconcaptcha/js/iconcaptcha.min.js' => [
    'path' => '@fabianwennink/iconcaptcha/js/iconcaptcha.min.js',
],
'@fabianwennink/iconcaptcha/css/iconcaptcha.min.css' => [
    'path' => '@fabianwennink/iconcaptcha/css/iconcaptcha.min.css',
    'type' => 'css',
],

type is what makes AssetMapper treat the entry as a stylesheet. importmap:require infers it from the .css extension; a hand-written entry does not — it defaults to js and the stylesheet silently never loads.

Import the stylesheet from your entrypoint:

// assets/app.js
import '@fabianwennink/iconcaptcha/css/iconcaptcha.min.css';

AssetMapper turns that import into a <link rel="stylesheet"> because of the type: css entry.

Do not import the JavaScript from your entrypoint. It is not an ES module: it declares var IconCaptcha = ... at the top level and relies on a classic <script> to hoist that onto window. Imported as a module the declaration stays module-scoped and the global never appears. The Stimulus controller therefore resolves the specifier through the importmap and loads the file as a classic script itself — which is why the importmap entry above is required even though nothing appears to import it.

Without AssetMapper

php bin/console iconcaptcha:install-assets

This copies the files into public/bundles/iconcaptcha/, which is where iconcaptcha_script() and iconcaptcha_style() point. Re-run it after every composer update of the library. Symfony's own assets:install cannot do this: it only publishes a bundle's own public/ directory, and these files are not in one.

Styling the widget

The library ships one fixed look in two variants, picked with the theme setting or the field's theme option. There are no CSS custom properties, so restyling means redeclaring the library's own selectors:

  • Load your stylesheet after the library's — your rules match its selectors exactly, so specificity ties and load order decides. With AssetMapper, import your file after @fabianwennink/iconcaptcha/css/iconcaptcha.min.css in the entrypoint.
  • Keep the theme class in colour rules. Every colour is scoped to .iconcaptcha-theme-light / .iconcaptcha-theme-dark; matching it keeps the tie.

The markup is .iconcaptcha-widget with a .iconcaptcha-modal inside; the client rewrites the body on every state change and signals the current state on the widget itself: iconcaptcha-init (resting), no class (the challenge), iconcaptcha-success, iconcaptcha-error. .iconcaptcha-row, wrapping the widget and the violation, is this bundle's — it gets no class from your form theme, so row spacing has to be restated, and the widget is not an input so it never gets an is-invalid.

Two things not to touch:

  • The size of __body-icons and __body-selection, capped at 320px. A click is sent as an x coordinate plus the element's width, which the server divides by the icon count. Widen either and clicks land on the wrong icon.
  • The name of the captcha-breathing keyframes. They set border-color on every step, beating any colour on the element. To recolour the resting circle, redeclare the whole animation under the same name — a later definition replaces the library's.
/* assets/styles/iconcaptcha.css, imported after the library's stylesheet */

.iconcaptcha-widget {
    width: 100%;
    max-width: 100%;
}

.iconcaptcha-widget.iconcaptcha-theme-light {
    background: var(--bs-body-bg);
    border: var(--bs-border-width) solid var(--bs-border-color);
}

/* Every accented pixel: resting circle, loading spinner, selection dot. */
.iconcaptcha-widget.iconcaptcha-init .iconcaptcha-modal__body-circle,
.iconcaptcha-widget .iconcaptcha-modal__body .captcha-loader {
    border-color: #e55b25;
}

.iconcaptcha-widget .iconcaptcha-modal__body-selection > i {
    background: #e55b25;
}

The icons and their separators are pixels in a server-rendered PNG, not DOM, so CSS cannot reach them: challenge.border: false drops the separators and theme picks the icon set. The typeface and the credit line come from the client, not the stylesheet:

$builder->add('captcha', IconCaptchaType::class, [
    'client_options' => ['general' => ['fontFamily' => 'Poppins', 'showCredits' => true]],
]);

fontFamily is written onto the widget as an inline style; leaving it unset is usually better, because the library's stylesheet already sets font-family: inherit and the widget then picks up the surrounding form's font on its own.

Configuration reference

Every key is optional; the values below are the defaults.

# config/packages/icon_captcha.yaml
icon_captcha:
    enable: true                    # false renders nothing and validates everything
    theme: light                    # light | dark

    # null auto-detects: true when symfony/stimulus-bundle is installed. The Stimulus
    # branch is what makes the widget work inside a UX Live Component.
    use_stimulus: ~

    challenge:
        available_icons: 250        # size of the icon pool
        icon_amount:
            min: 5
            max: 8
        rotate: true                # randomly rotate icons
        border: true                # draw separators between icons
        generator: gd               # gd | imagick

    validation:
        inactivity_expiration: 120  # seconds a challenge may sit untouched
        completion_expiration: 300  # seconds a solved captcha stays valid
        reuse_solved: true          # accept the same solve again within that window
        attempts:
            enabled: true
            amount: 5               # wrong picks before a timeout
            timeout: 60             # seconds of timeout

    cors:
        enabled: false
        origins: []

    hooks:                          # class names, see the library documentation
        init: null                  # InitHookInterface
        generation: null            # GenerationHookInterface
        selection: null             # SelectionHookInterface

There is deliberately no storage key. Challenges always go through Symfony's session, so the library's own storage drivers are unreachable — configuring one would only produce a TypeError on the first request.

Translations

The constraint message lives in the validators domain under the invalid_iconcaptcha key. English and Slovak are bundled; override the key in your own catalogue to change the wording.

The widget's own strings — "Verify that you are human.", the instruction above the icons, the error and timeout messages — are rendered by the client, not by Twig, so they are handed over as the locale client option, translated into the application's locale. They live in the iconcaptcha domain:

Key
iconcaptcha.initialization.loading shown while the challenge is being fetched
iconcaptcha.initialization.verify the widget's resting state
iconcaptcha.header the instruction above the icons; may contain HTML
iconcaptcha.correct shown once solved
iconcaptcha.incorrect.title / .subtitle wrong icon picked
iconcaptcha.timeout.title / .subtitle too many wrong picks

English and Slovak ship with the bundle. Override a key in your own iconcaptcha catalogue, or per form with client_options:

$builder->add('captcha', IconCaptchaType::class, [
    'client_options' => ['locale' => ['correct' => 'Thanks!']],
]);

Development

composer test        # phpunit
composer phpstan     # static analysis, level 6
composer check-cs    # php-cs-fixer, dry run
composer fix-cs      # php-cs-fixer, apply
./tests_e2e/run.sh   # end-to-end run against a throwaway skeleton project

License

MIT. The wrapped IconCaptcha library is MIT as well.