Integrations

Ready-made packages for Laravel, Symfony, WordPress and WoltLab Suite — plus the guide for one of your own.

Laravel integration

1. Add the repository

Add the CaptchaCore repository to your composer.json:

{
    "repositories": [
        {
            "type": "composer",
            "url": "https://captchacore.eu/packages"
        }
    ]
}

2. Install the package

composer require captchacore/laravel

# optional
php artisan vendor:publish --tag=captchacore-config
php artisan vendor:publish --tag=captchacore-lang

Laravel registers the ServiceProvider, middleware alias, Blade component and facade via auto-discovery. You only need to publish the configuration if you want to change values outside .env.

3. Configure .env

CAPTCHACORE_URL=https://api.captchacore.eu
CAPTCHACORE_SITE_KEY=cc_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
CAPTCHACORE_SECRET_KEY=cc_sec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

# optional
CAPTCHACORE_TIMEOUT=3             # Sekunden
CAPTCHACORE_FAIL_OPEN=true        # bei Störung des Dienstes durchlassen
CAPTCHACORE_WIDGET_MODE=          # interactive | visible | invisible
CAPTCHACORE_WIDGET_THEME=         # auto | light | dark
CAPTCHACORE_WIDGET_COLOR=         # z. B. #34d399
CAPTCHACORE_WIDGET_LABEL=
CAPTCHACORE_WIDGET_BRAND=
CAPTCHACORE_WIDGET_SIZE=          # normal | compact
CAPTCHACORE_WIDGET_LANG=          # de | en | fr | es …
CAPTCHACORE_WIDGET_ENDPOINT=eu    # eu | global
CAPTCHACORE_WIDGET_CDN_URL=       # eigener Widget-Host (optional)

Widget options left empty are not output by the package: the widget then uses the settings from the customer area and the language of the page.

Blade component

<form method="post" action="/contact">
    @csrf
    <!-- … -->
    <x-captchacore::widget form-type="contact" />

    @error('captchacore')
        <p class="error">{{ $message }}</p>
    @enderror

    <button type="submit">Absenden</button>
</form>

The form type of the widget and of the check must be identical: form-type="contact" belongs to captchacore:contact or new CaptchaCoreToken('contact'). Each type has its own difficulty; a different type is rejected.

Middleware

use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;

Route::post('/contact', [ContactController::class, 'send'])
    ->middleware('captchacore:contact');

POST, PUT, PATCH and DELETE are checked. On rejection the middleware redirects back with the input and the message under the key captchacore. Requests via fetch() or XHR receive JSON with status 422 and errors.captchacore — send Accept: application/json for this (example under “Submitting forms via fetch()”).

Validation rule

use CaptchaCore\Laravel\Rules\CaptchaCoreToken;

'captchacore_token' => [new CaptchaCoreToken('contact')],

The rule also applies without a token — required is not needed and would produce a second message. The message is stored under the field name: @error('captchacore_token').

Facade

use CaptchaCore\Laravel\Facades\CaptchaCore;

$result = CaptchaCore::verify($request->input('captchacore_token'), 'comment');

if (! $result->passed()) {
    return back()->withInput()->withErrors(['captchacore' => __('captchacore::messages.failed')]);
}

Messages, logs and proxies

  • The messages ship in German, English, Spanish and French and follow the app language. Customise them after vendor:publish --tag=captchacore-lang in lang/vendor/captchacore.
  • Rejections are logged as info with reasons and risk score, outages with fail-open as warning — the token itself is never logged.
  • Behind a load balancer, CDN or reverse proxy, configure TrustProxies ($middleware->trustProxies(at: …)), otherwise CaptchaCore sees the proxy’s IP for every visitor.
  • Strict CSP: the component takes the Vite nonce (Vite::useCspNonce()) or nonce="…" and puts it on the script tag.

Tests

use CaptchaCore\Laravel\Facades\CaptchaCore;

CaptchaCore::fake();   // jede Prüfung besteht; wird bei jedem App-Start zurückgesetzt
$this->post('/contact', ['captchacore_token' => 'fake'])->assertRedirect();

Symfony Bundle

For Symfony 6.4 LTS, 7.x and 8.x. Four integration paths: FormType, validator constraint, controller attributes and a security badge for the login.

1. Install

Add the CaptchaCore repository to your composer.json:

{
    "repositories": [
        { "type": "composer", "url": "https://captchacore.eu/packages" }
    ]
}
composer require captchacore/captchacore-bundle

Register the bundle in config/bundles.php and create the configuration as config/packages/captchacore.yaml: Both steps are manual — there is no Flex recipe for the bundle.

// config/bundles.php
CaptchaCore\SymfonyBundle\CaptchaCoreBundle::class => ['all' => true],

# config/packages/captchacore.yaml
captchacore:
    service_url: '%env(CAPTCHACORE_URL)%'
    site_key: '%env(CAPTCHACORE_SITE_KEY)%'
    secret_key: '%env(CAPTCHACORE_SECRET_KEY)%'

2. Configure .env

CAPTCHACORE_URL=https://api.captchacore.eu
CAPTCHACORE_SITE_KEY=cc_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
CAPTCHACORE_SECRET_KEY=cc_sec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx

FormType (recommended)

use CaptchaCore\SymfonyBundle\Form\Type\CaptchaCoreType;

$builder
    ->add('email', EmailType::class)
    ->add('message', TextareaType::class)
    ->add('captcha', CaptchaCoreType::class, [
        'form_type' => 'contact',
    ]);

The recommended way for HTML forms: a rejection appears as a normal form error. The widget always sends its token as captchacore_token; the FormType takes it over before validation.

Controller attributes

use CaptchaCore\SymfonyBundle\Security\Attribute\RequiresCaptcha;

#[Route('/contact', methods: ['POST'])]
#[RequiresCaptcha(formType: 'contact')]
public function submit(Request $request): Response { /* ... */ }

A rejection ends with status 403 (logged as info, not error) — suitable for endpoints called via JavaScript. With throwOnFailure: false you get JSON with status 422 instead. For HTML forms use the FormType. The attribute does not protect the login, because the firewall authenticates before the controller runs.

Validator constraint (DTOs)

For #[MapRequestPayload] Symfony additionally needs symfony/serializer and symfony/property-access.

use CaptchaCore\SymfonyBundle\Validator\CaptchaCoreToken;

final class ContactDto {
    public function __construct(
        #[Assert\NotBlank]
        public string $email,

        #[CaptchaCoreToken(formType: 'contact')]
        public string $captchaToken,
    ) {}
}

Programmatic

use CaptchaCore\SymfonyBundle\Client\CaptchaCoreClient;

$result = $this->captcha->verify($token, 'login');

if ($result->blocked()) {
    throw new AccessDeniedHttpException();
}

// Properties: valid, riskScore, action, confidence, reasons, stepUp

Twig (standalone)

{{ captchacore_widget(form_type: 'contact') }}

@{# Strikte CSP: Nonce Ihrer Seite mitgeben #}
{{ captchacore_widget(form_type: 'contact', nonce: page_nonce) }}
{{ form_row(form.captcha, {nonce: page_nonce}) }}

Without form_type, default_form_type applies, as with the FormType. The script is included only once per page, even with several widgets or mixed with the FormType. The widget uses the nonce of its script tag for its style element; allow it in script-src and style-src-elem. Set your own widget host with widget.cdn_url.

Protecting the login

Symfony’s standard form_login is not protected: only logins carrying a CaptchaCoreBadge are checked. Replace form_login with your own authenticator that adds the badge — the token is then checked before the password.

// src/Security/LoginFormAuthenticator.php
namespace App\Security;

use CaptchaCore\SymfonyBundle\Security\CaptchaCoreAuthenticator;
use CaptchaCore\SymfonyBundle\Security\CaptchaCoreBadge;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\CsrfTokenBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\RememberMeBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Credentials\PasswordCredentials;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\SecurityRequestAttributes;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

final class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public function __construct(private readonly UrlGeneratorInterface $urlGenerator) {}

    public function authenticate(Request $request): Passport
    {
        $payload = $request->getPayload();
        $username = $payload->getString('_username');
        $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $username);

        return new Passport(
            new UserBadge($username),
            new PasswordCredentials($payload->getString('_password')),
            [
                new CaptchaCoreBadge(CaptchaCoreAuthenticator::extractToken($request) ?? '', 'login'),
                new CsrfTokenBadge('authenticate', $payload->getString('_csrf_token')),
                new RememberMeBadge(),
            ],
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        $target = $this->getTargetPath($request->getSession(), $firewallName);

        return new RedirectResponse($target ?? $this->urlGenerator->generate('app_home'));
    }

    protected function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate('app_login');
    }
}
# config/packages/security.yaml — statt form_login
security:
    firewalls:
        main:
            custom_authenticator: App\Security\LoginFormAuthenticator
            logout:
                path: app_logout

@{# templates/security/login.html.twig, im <form method="post"> #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
{{ captchacore_widget(form_type: 'login') }}

app_login is the route of your login page (GET shows the form, POST is handled by the authenticator), app_home the page after login. The complete example with controller and template is in the bundle’s README.

Tests

CaptchaCoreClient::fake(VerificationResult::allow());
$this->client->request('POST', '/contact', [...]);
self::assertResponseIsSuccessful();

WordPress Plugin

  1. Install the plugin — in WordPress, go to Plugins → Add New and search for “CaptchaCore” (entry in the WordPress directory). Updates then come from WordPress itself. Alternatively upload the ZIP from your account.
  2. Activate it under Plugins
  3. Open Settings > CaptchaCore
  4. Enter the service URL, site key and secret key
  5. Choose widget mode, theme and color
  6. Enable the forms you want (login, registration, comments, etc.)

No code required. The plugin automatically embeds the widget in all enabled WordPress forms and verifies tokens server-side.

WoltLab Suite

The package is distributed through the official WoltLab plugin store. Installation and every future update run through it — there is no separate download in your account.

  1. Buy the package in the plugin store and install it in the ACP under Packages → Install package
  2. Enter your credentials under Options → Security → Anti-Spam → CaptchaCore: service URL, site key and secret key
  3. Choose the appearance: mode, theme, accent colour and language — “auto” follows the language of your forum.
  4. Select CaptchaCore as the captcha under Options → Security → Anti-Spam → Captcha — it then applies to registration, the contact form and every other system form
Setting Meaning
Endpoint EU standard or worldwide endpoint. EU keeps all verification data inside the EU; the global endpoint speeds things up for distant visitors.
Mode interactive, visible or invisible — the same three modes as everywhere else, see widget modes.
Behaviour during an outage If CaptchaCore cannot be reached, the plugin lets the form through by default and logs the error. Switch it off if you would rather block in case of doubt.

One package for 6.1 and 6.2. The handler verifies every token server-side against the V2 API; the extra check (“step_up”) is handled by the widget itself, and the visitor gets a message of its own instead of an error.

Build your own integration

Do you want to bring CaptchaCore into a system with no package yet, such as Shopware, Joomla or your own framework? It takes a few hours. This order has proven itself.

01

Deliver the script

Include captchacore-v2.min.js from the EU endpoint and pass the site key and service URL as data attributes. In a CMS this belongs in the hook that registers frontend scripts. Only load the script on pages that actually contain a protected form.

02

Mark the forms

Put data-captchacore on the form and insert an empty container with data-captchacore-widget. data-form-type decides which policy applies. Several forms on one page are no problem, each gets its own widget.

03

Check the token server side

Read the captchacore_token field from the request and send it with your secret key to /api/v2/verify. Check decision, and do not treat step_up like block.

04

Handle outages

Set a timeout of about three seconds and decide deliberately whether an outage lets traffic through or blocks it. Make it configurable in the plugin, the answer differs from form to form.

05

Account for late loading

If your system swaps forms via AJAX, call CaptchaCoreV2.attach() afterwards.

06

Offer settings

Site key, secret key, choice of protected forms, appearance and the behaviour on outage belong in your plugin's configuration screen.

Templates to copy from

All supplied integrations follow the same pattern. For a Symfony-based system such as Shopware 6 the Symfony bundle is the closest template, for a classic CMS the WordPress plugin.

Check before release

  • Form submitted without JavaScript — does your fail rule apply?
  • Token used twice — the second check must fail, the nonce is single use.
  • Token submitted after more than five minutes — the widget renews it by itself, verify that in a long-running test.
  • Two protected forms on one page — both must work independently.
  • Form loaded in via AJAX — does the widget appear?
  • The secret key appears nowhere in the HTML or in JavaScript.
  • A response with decision=step_up — the user must not end up in a dead end.

Building an integration and want us to list it here or help maintain it? Get in touch — we provide test accounts and a contact person.

← Back Getting started Next → Widget