tito10047 / iconcaptcha-bundle
A self-hosted, customizable, easy-to-implement and user-friendly captcha for PHP.
Package info
github.com/tito10047/icon-captcha-bundle
Type:symfony-bundle
pkg:composer/tito10047/iconcaptcha-bundle
Requires
- php: >=8.2
- fabianwennink/iconcaptcha: ^4.0
- symfony/filesystem: ^6.4|^7.4|^8.1
- symfony/form: ^6.4|^7.4|^8.1
- symfony/framework-bundle: ^6.4|^7.4|^8.1
- symfony/translation: ^6.4|^7.4|^8.1
- symfony/twig-bundle: ^6.4|^7.4|^8.1
- symfony/validator: ^6.4|^7.4|^8.1
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.92
- phpstan/phpstan: ^2.1
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^10.5
- symfony/asset: ^6.4|^7.4|^8.1
- symfony/asset-mapper: ^6.4|^7.4|^8.1
- symfony/browser-kit: ^6.4|^7.4|^8.1
- symfony/console: ^6.4|^7.4|^8.1
- symfony/css-selector: ^6.4|^7.4|^8.1
- symfony/dotenv: ^6.4|^7.4|^8.1
- symfony/stimulus-bundle: ^2.30
- symfony/ux-live-component: ^2.30
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 achangeevent, 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.cssin 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-iconsand__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-breathingkeyframes. They setborder-coloron 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.