Введение
Yandex SmartCaptcha — сервис Yandex Cloud для отсечения ботов. Схема работы простая: виджет на странице выдаёт одноразовый токен, сервер сайта передаёт его сервису на проверку и получает статус ok или failed. Задание пользователю показывается лишь тогда, когда запрос выглядит подозрительно. Капча не заменяет другие меры защиты, а дополняет их: throttle, валидацию и honeypot-поле.
Официальная документация описывает протокол, но не отвечает на вопросы, которые возникают при внедрении. Как встроить проверку в Laravel? Что делать, если сервис не отвечает? Как передать настоящий IP клиента за прокси? Как тестировать форму, не выходя в сеть? Дальше разобран сквозной пример «форма → правило валидации → тест», а также варианты для Livewire и API.
Понадобится аккаунт Yandex Cloud. Условия и цены приведены в прайс-листе SmartCaptcha; они меняются, поэтому в статье их нет. Код проверялся на PHP 8.5.9 (в composer.json указано ^8.3), Laravel 13.30.1 и Livewire 4.4.3.
Создание капчи и ключи
Капчу создают в консоли управления Yandex Cloud: раздел SmartCaptcha, кнопка «Создать капчу», затем имя и список сайтов, где она будет стоять (в примере из документации — my-shop.com). Допустим ли localhost для локальной разработки, документация не говорит. Надёжнее завести для отладки отдельную капчу с тестовым доменом.
Сервис выдаёт два ключа:
| Ключ | Префикс | Где используется | Секретность |
|---|---|---|---|
Клиентский (client_key) |
ysc1_ |
В HTML страницы, в атрибуте data-sitekey |
Публичный |
Серверный (server_key) |
ysc2_ |
Только на бэкенде, в параметре secret |
Приватный |
Ключи создаются один раз и потом не меняются. Если серверный ключ утёк, по рекомендации документации нужно создать новую капчу и отказаться от старой.
Значения лежат в .env, а код обращается к ним через config/services.php:
SMARTCAPTCHA_ENABLED=true
SMARTCAPTCHA_CLIENT_KEY=ysc1_...
SMARTCAPTCHA_SERVER_KEY=ysc2_...
SMARTCAPTCHA_TIMEOUT=3
SMARTCAPTCHA_FAIL_OPEN=true
// config/services.php
'smartcaptcha' => [
'enabled' => env('SMARTCAPTCHA_ENABLED', false),
'client_key' => env('SMARTCAPTCHA_CLIENT_KEY'),
'server_key' => env('SMARTCAPTCHA_SERVER_KEY'),
'url' => 'https://smartcaptcha.cloud.yandex.ru/validate',
'timeout' => (int) env('SMARTCAPTCHA_TIMEOUT', 3),
'fail_open' => env('SMARTCAPTCHA_FAIL_OPEN', true),
],
Значение по умолчанию для timeout нужно обязательно: (int) null превращается в 0, а в Guzzle нуль означает «без ограничения». В таком случае зависший сервис капчи потянет за собой и форму сайта.
Виджет в Blade
Согласно документации, скрипт captcha.js ищет на странице все div с классом smart-captcha и рисует в них виджет. Настройки передаются data-атрибутами: data-sitekey (клиентский ключ), data-hl (язык: ru, en, be, kk, tt, uk, uz, tr) и data-callback (имя JS-функции, получающей токен). Тестовый режим (test) и невидимая капча (invisible) через data-атрибуты не задаются: они доступны только в расширенном методе, через window.smartCaptcha.render().
Виджет удобно оформить анонимным Blade-компонентом, который при выключенной проверке ничего не выводит:
{{-- resources/views/components/smart-captcha.blade.php --}}
@props(['lang' => 'ru'])
@if (config('services.smartcaptcha.enabled'))
@once
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js" defer></script>
@endonce
<div
{{ $attributes->merge(['class' => 'smart-captcha']) }}
data-sitekey="{{ config('services.smartcaptcha.client_key') }}"
data-hl="{{ $lang }}"
></div>
@endif
В форму добавляется одна строка <x-smart-captcha />, а ошибка выводится обычным способом — через @error('smart-token'). Когда пользователь прошёл проверку, виджет сам кладёт в контейнер скрытое поле <input type="hidden" name="smart-token" value="...">, так что в запросе токен приходит под именем smart-token. Тот же компонент подходит для форм входа и регистрации из стартовых наборов (Стартовые наборы Laravel 12–13 и laravel/ui: React, Vue, Livewire).
Токен одноразовый, поэтому после возврата формы с ошибками валидации старый уже не сработает. При обычной отправке страница перезагружается, виджет рисуется заново, и проверку приходится пройти повторно. Вручную сбрасывать виджет нужно только там, где страница не перезагружается: при AJAX-отправке и в Livewire.
Серверная проверка токена
Проверка сводится к POST-запросу на https://smartcaptcha.cloud.yandex.ru/validate с телом x-www-form-urlencoded и параметрами secret, token и ip. Параметр ip не обязателен, но документация просит его передавать: так капча работает точнее. В ответе приходит JSON с полями status (ok или failed), message и, если проверка пройдена, host — сайт, на котором её прошли. Токен живёт 5 минут и принимается только один раз.
Отдельный пакет не нужен, достаточно Http::asForm(). Логику лучше вынести в сервис, чтобы правило валидации осталось коротким:
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SmartCaptchaVerifier
{
public function verify(string $token, ?string $ip = null): bool
{
try {
$response = Http::asForm()
->timeout(config('services.smartcaptcha.timeout'))
->post(config('services.smartcaptcha.url'), array_filter([
'secret' => config('services.smartcaptcha.server_key'),
'token' => $token,
'ip' => $ip,
]));
} catch (ConnectionException $e) {
Log::warning('SmartCaptcha: сервис недоступен', ['error' => $e->getMessage()]);
return $this->whenServiceFails();
}
if ($response->status() !== 200) {
Log::error('SmartCaptcha: ответ не 200', [
'status' => $response->status(),
'message' => $response->json('message'),
]);
return $this->whenServiceFails();
}
return $response->json('status') === 'ok';
}
// true — пропустить (fail-open), false — отклонить (fail-closed)
private function whenServiceFails(): bool
{
return (bool) config('services.smartcaptcha.fail_open');
}
}
Правило валидации реализует ValidationRule. Статический метод rules() отдаёт набор правил для поля, а при выключенной капче токен не требует:
namespace App\Rules;
use App\Services\SmartCaptchaVerifier;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class SmartCaptcha implements ValidationRule
{
public static function rules(): array
{
return config('services.smartcaptcha.enabled')
? ['required', 'string', new self]
: ['nullable', 'string'];
}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! app(SmartCaptchaVerifier::class)->verify($value, request()->ip())) {
$fail('Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.');
}
}
}
В FormRequest правило подключается так:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'message' => ['required', 'string', 'max:2000'],
'smart-token' => SmartCaptcha::rules(),
];
}
За один запрос токен проверяется один раз. Если то же правило подключено и к другим сценариям, следует учитывать: повторный вызов validate() с тем же токеном вернёт failed.
Сбой сервиса и таймаут
На случай недоступности сервиса или ответа не 200 документация даёт прямую рекомендацию: «ошибки HTTP-протокола (код ответа не 200) рекомендуется обрабатывать как ответ сервиса "status": "ok"». Идея в том, чтобы не задерживать и не блокировать пользователей. Это подход fail-open, и в примере он включается параметром SMARTCAPTCHA_FAIL_OPEN=true.
Обратный вариант, fail-closed (при сбое запрос отклоняется), из документации не вытекает: это решение самого разработчика. Что выбрать, зависит от формы:
| Подход | Плюс | Минус | Где уместен |
|---|---|---|---|
| fail-open | Форма работает при сбое капчи | На время сбоя защиты нет | Обратная связь, комментарии |
| fail-closed | Защита действует всегда | При сбое форма недоступна | Вход, регистрация, платежи |
При любом выборе сбой стоит писать в лог и не считать капчу единственным барьером: на маршруте остаётся throttle (например, throttle:5,1), а записи в логе позволяют заметить проблему раньше, чем придут жалобы.
Есть подвох, о котором документация молчит. Если серверный ключ неверный, сервис отвечает не 200, а 403. Запрос к реальному адресу с ключом-заглушкой вернул 403 и тело {"status":"failed","message":"Authentication failed. Invalid secret."}. При fail-open и правиле «не 200 значит ok» опечатка в SMARTCAPTCHA_SERVER_KEY незаметно отключит капчу целиком. Поэтому сервис пишет любой ответ не 200 в лог на уровне error вместе со статусом и message, и именно на такую запись стоит повесить оповещение.
Таймаут нужно подбирать по собственным замерам в целевой среде. При пробном запуске из Docker запрос с таймаутом в 1 секунду к реальному адресу завершился ошибкой cURL error 28: слишком жёсткий лимит превращает исправную работу сервиса в «сбой». Три секунды в примере — точка отсчёта, а не рекомендация.
IP клиента за прокси
Параметр ip берётся из $request->ip(). Если приложение работает за балансировщиком или reverse proxy (nginx, Cloudflare) и доверенные прокси не настроены, Laravel вернёт адрес самого прокси, и сервис получит неверный IP. Доверенные прокси задаются в bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
// '*' доверяет любому прокси; при известных адресах лучше перечислить их
$middleware->trustProxies(at: '*');
})
Значение '*' годится, если приложение доступно только через балансировщик. Когда сервер открыт напрямую, клиент может подделать заголовок X-Forwarded-For, и тогда нужен явный список адресов. В тесте набора (в статье не приведён) запрос с REMOTE_ADDR=10.0.0.1 и заголовком X-Forwarded-For: 203.0.113.7 передаёт сервису именно 203.0.113.7.
Livewire
Форма Livewire уходит без перезагрузки страницы, поэтому три вещи приходится делать самостоятельно: защитить контейнер от перерисовки, положить токен в свойство компонента и сбросить виджет после отправки.
class ContactForm extends Component
{
public string $name = '';
public string $message = '';
public string $captchaToken = '';
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'message' => ['required', 'string', 'max:2000'],
'captchaToken' => SmartCaptcha::rules(),
];
}
public function submit(): void
{
try {
$this->validate();
} catch (ValidationException $e) {
// токен уже потрачен на проверке, виджет нужно сбросить
$this->dispatch('smartcaptcha-reset');
throw $e;
}
// здесь отправка письма или сохранение обращения
$this->reset('name', 'message', 'captchaToken');
$this->dispatch('smartcaptcha-reset');
}
}
В шаблоне контейнер обёрнут в wire:ignore, скрипт подключается расширенным методом (render=onload&onload=...), а токен попадает в свойство из callback. Методы render(), reset() и subscribe() (событие token-expired) описаны в документации в разделе «Методы виджета»:
<div wire:ignore data-captcha></div>
@error('captchaToken') <p class="error">{{ $message }}</p> @enderror
@assets
<script>
window.smartCaptchaLoaded = () => window.dispatchEvent(new Event('smartcaptcha:ready'));
</script>
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=smartCaptchaLoaded" defer></script>
@endassets
@script
<script>
const init = () => {
const widgetId = window.smartCaptcha.render($wire.$el.querySelector('[data-captcha]'), {
sitekey: @js(config('services.smartcaptcha.client_key')),
hl: 'ru',
callback: (token) => { $wire.captchaToken = token; },
});
// токен живёт 5 минут: по истечении срока поле очищается
window.smartCaptcha.subscribe(widgetId, 'token-expired', () => { $wire.captchaToken = ''; });
$wire.on('smartcaptcha-reset', () => window.smartCaptcha.reset(widgetId));
};
window.smartCaptcha ? init() : window.addEventListener('smartcaptcha:ready', init, { once: true });
</script>
@endscript
Свойство captchaToken не стоит связывать через wire:model.live и помечать атрибутом #[Validate]. Иначе валидация на каждое обновление израсходует одноразовый токен раньше, чем пользователь нажмёт кнопку отправки. Проверять токен нужно только внутри submit().
API и SPA
В API правило остаётся тем же, отличается лишь имя поля: клиент присылает токен в JSON под любым согласованным именем, например smart_captcha_token. Если проверка не пройдена, возвращается обычный ответ 422 с ошибкой в этом поле.
class AuthLoginRequest extends FormRequest
{
public function rules(): array
{
return [
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'smart_captcha_token' => SmartCaptcha::rules(),
];
}
}
SPA берёт токен из callback виджета и добавляет его в тело запроса. На маршрут входа стоит повесить throttle. Если API работает с токенами доступа (см. авторизацию по Bearer-токену в Laravel), капча прикрывает только выдачу токена, а последующие запросы с ним проверяются как обычно.
Пакет или своя реализация
Собственная реализация — это два небольших класса без лишних зависимостей. Готовые пакеты оправданы, если нужны готовые Blade-компоненты или единый клиент к другим API Yandex Cloud. Данные в таблице взяты из Packagist на 30.09.2026:
| Пакет | Последний релиз | Требования | Описание на Packagist |
|---|---|---|---|
dsoloview/laravel-yandex-captcha |
2.2.0, 21.10.2025 | Laravel ^10 (в последнем релизе) |
Yandex captcha integration for Laravel |
vvb/yandex-smart-captcha |
v1.0.2, 07.08.2026 | PHP ^8.1, Laravel 10–13 |
Yandex Smart Captcha integration for Laravel |
tigusigalpa/yandex-smartcaptcha-php |
v1.0.0, 29.11.2025 | PHP ^8.0, Guzzle |
PHP SDK: управление капчами, валидация, интеграция с Laravel |
| Свой код | — | Laravel 13, PHP 8.3+ | Сервис, правило и компонент из статьи |
Перед выбором пакета стоит проверить, какие версии Laravel и PHP заявлены в свежем релизе и как он ведёт себя при сбое сервиса: единого стандарта для fail-open и fail-closed нет.
Тесты
Форму можно тестировать без сети: Http::fake() подменяет ответ на адрес smartcaptcha.cloud.yandex.ru/*, а Http::preventStrayRequests() роняет тест, если код пошлёт незапланированный запрос.
protected function setUp(): void
{
parent::setUp();
config([
'services.smartcaptcha.enabled' => true,
'services.smartcaptcha.client_key' => 'ysc1_client_placeholder',
'services.smartcaptcha.server_key' => 'ysc2_server_placeholder',
'services.smartcaptcha.fail_open' => true,
]);
Http::preventStrayRequests();
}
// $this->form() — вспомогательный метод теста с полями name, message и smart-token
public function test_failed_status_rejects(): void
{
Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::response(['status' => 'failed', 'message' => ''])]);
$this->post('/contact', $this->form())->assertSessionHasErrors('smart-token');
}
public function test_server_error_fail_closed(): void
{
config(['services.smartcaptcha.fail_open' => false]);
Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::response('', 500)]);
$this->post('/contact', $this->form())->assertSessionHasErrors('smart-token');
}
public function test_connection_error_fail_open(): void
{
Http::fake(['smartcaptcha.cloud.yandex.ru/*' => Http::failedConnection()]);
$this->post('/contact', $this->form())->assertSessionHasNoErrors();
}
public function test_disabled_makes_no_request(): void
{
config(['services.smartcaptcha.enabled' => false]);
Http::fake();
$this->post('/contact', $this->form(['smart-token' => null]))->assertSessionHasNoErrors();
Http::assertNothingSent();
}
Сценарий с повторным токеном удобно собрать на Http::sequence(): первый ответ ok, второй failed. Содержимое запроса проверяется через Http::assertSent() — секретный ключ, токен и IP приходят в теле формы. Если нужно отключить проверку целиком, достаточно параметра services.smartcaptcha.enabled: правило вернёт nullable, и обращений к сети не будет.
Прогон 15 тестов полного набора автора (выше показаны четыре; в наборе также форма, повторный токен, сбои, 403, API, Livewire, IP за прокси) завершился так:
Tests: 15 passed (40 assertions)
Тесты подтверждают серверную часть, но не работу настоящего виджета в браузере: для этого нужны реальный клиентский ключ и зарегистрированный домен.
Типичные ошибки
| Симптом или ответ | Вероятная причина |
|---|---|
Invalid or expired Token. |
Токен использован повторно, прошло больше 5 минут или токен повреждён |
Authentication failed. No secret provided. |
В запросе нет secret (пустой SMARTCAPTCHA_SERVER_KEY, кэш конфигурации). В документации текст записан иначе: Secret has not provided. |
Authentication failed. Invalid secret. (HTTP 403) |
Неверный серверный ключ, например ключ от другой капчи |
status: failed и пустой message |
Сервис счёл запрос ботом; это штатный отказ, а не ошибка запроса |
status: ok, но host пустой |
По документации может означать блокировку облака или внутренний сбой сервиса |
| Виджет не появляется | Домен не указан в списке сайтов капчи, блокировщик рекламы, Content-Security-Policy без домена smartcaptcha.cloud.yandex.ru |
| Задание не показывается при проверке | Сервис показывает его только подозрительным запросам; документация советует открыть страницу в режиме инкогнито или через VPN |
Поле message предназначено только для диагностики, так рекомендует документация: условия в коде должны опираться на status.
Чек-лист
- Серверный ключ хранится только в
.envи не попадает в HTML, репозиторий и логи. - У
timeoutесть значение по умолчанию, подобранное по собственным замерам. - В запрос передаётся
ip, а доверенные прокси настроены. - Политика при сбое (fail-open или fail-closed) выбрана осознанно для каждой формы.
- Ответы не 200 пишутся в лог на уровне
error, включая 403 при неверном ключе. - Токен проверяется один раз за запрос; в Livewire виджет сбрасывается после отправки и после ошибки валидации.
- Маршрут защищён
throttle, а тесты используютHttp::fake()иHttp::preventStrayRequests().
Итог
Чтобы подключить SmartCaptcha к Laravel, хватает Http::asForm(), правила ValidationRule и Blade-компонента: серверная часть укладывается в несколько десятков строк. Главные решения остаются за разработчиком: как вести себя при сбое сервиса, какой таймаут выбрать и как сбрасывать виджет без перезагрузки страницы. Отдельно стоит помнить про 403 при неверном ключе: правило «не 200 значит ok» не должно втихую отключать защиту.