laravel-slide-captcha maintained by codedart
Laravel Slide CAPTCHA
CAPTCHA visual self-hosted para Laravel, baseado no desafio de arrastar uma peça até a posição correta da imagem.
O pacote gera um desafio, recorta uma peça da imagem, salva temporariamente os arquivos gerados em um disco privado, normalmente S3, e retorna URLs temporárias para o navegador. A posição correta fica somente no backend e é armazenada em cache por poucos segundos.
Introdução
O codedart/laravel-slide-captcha ajuda a proteger formulários Laravel contra envios automatizados.
Use esta biblioteca quando você precisa de um CAPTCHA simples, visual e controlado pela própria aplicação, sem depender de serviços externos como Google reCAPTCHA, hCaptcha ou Cloudflare Turnstile.
Ela resolve um problema comum em formulários públicos:
- Bots enviando formulários de contato.
- Cadastros automatizados.
- Tentativas repetidas em páginas sensíveis.
- Necessidade de validar interação humana sem enviar dados para provedores externos.
O usuário vê uma imagem, arrasta a peça até o ponto correto, gira a peça quando o desafio exigir rotação e, se acertar, recebe um token temporário. Esse token deve ser enviado junto com o formulário final.
Requisitos
- PHP
>= 7.4 - Laravel
>= 8 - Composer
- Extensão PHP
gd - Cache configurado no Laravel
- Disco de storage com suporte a URLs temporárias
- Recomendado: Redis para cache
- Recomendado: S3 ou storage compatível com S3 para armazenar as imagens geradas
Dependências usadas pelo pacote:
illuminate/supportilluminate/routingilluminate/cacheilluminate/filesystemilluminate/httpilluminate/validationintervention/image
Para usar S3 em um projeto Laravel, garanta que o driver esteja instalado e configurado. Em muitos projetos Laravel modernos, isso é feito com:
composer require league/flysystem-aws-s3-v3
Depois configure o disco s3 no .env da aplicação Laravel.
Instalação
Instale o pacote com Composer:
composer require codedart/laravel-slide-captcha
O Laravel deve registrar o service provider automaticamente.
Este pacote não exige migrations, não exige publicação de assets e não exige publicação de views para funcionar.
Os assets JavaScript e CSS são servidos por rotas internas do próprio pacote.
Em produção, o pacote serve automaticamente os assets minificados de resources/dist. Os fontes legíveis ficam em resources/assets.
As rotas criadas pelo pacote são:
GET /slide-captcha/assets/slide-captcha.css
GET /slide-captcha/assets/slide-captcha.js
GET /slide-captcha/new
POST /slide-captcha/verify
Para conferir se as rotas foram registradas:
php artisan route:list
Se você estiver testando este pacote localmente, antes de publicar no Packagist, adicione um repositório path no composer.json da aplicação Laravel:
{
"repositories": [
{
"type": "path",
"url": "../laravel-slide-captcha",
"options": {
"symlink": true
}
}
]
}
Depois instale:
composer require codedart/laravel-slide-captcha:@dev
Build dos assets
O pacote já inclui os arquivos minificados prontos para uso.
Se você alterar resources/assets/slide-captcha.js ou resources/assets/slide-captcha.css, gere a build novamente:
composer build-assets
Esse comando atualiza:
resources/dist/slide-captcha.min.js
resources/dist/slide-captcha.min.css
O objetivo é reduzir o tamanho dos arquivos enviados ao navegador e diminuir o custo de parse no dispositivo do usuário.
Testes
Para rodar a suíte de testes do pacote:
composer install
composer test
Os testes cobrem a máscara puzzle, a rotação, a análise de movimento e a validação angular.
Configuração
A configuração principal é feita pelo .env da aplicação Laravel.
Exemplo realista:
SLIDE_CAPTCHA_ENABLED=true
SLIDE_CAPTCHA_CACHE_STORE=redis
SLIDE_CAPTCHA_TTL=120
SLIDE_CAPTCHA_IMAGE_WIDTH=320
SLIDE_CAPTCHA_IMAGE_HEIGHT=180
SLIDE_CAPTCHA_PIECE_MIN_SIZE=42
SLIDE_CAPTCHA_PIECE_MAX_SIZE=58
SLIDE_CAPTCHA_TOLERANCE=8
SLIDE_CAPTCHA_ROTATION_ENABLED=true
SLIDE_CAPTCHA_ROTATION_STEP_DEGREES=15
SLIDE_CAPTCHA_ROTATION_MAX_DEGREES=90
SLIDE_CAPTCHA_ROTATION_TOLERANCE_DEGREES=8
SLIDE_CAPTCHA_ROUTE_PREFIX=slide-captcha
SLIDE_CAPTCHA_MIDDLEWARE=web
SLIDE_CAPTCHA_STORAGE_DISK=s3
SLIDE_CAPTCHA_GENERATED_PATH=slide-captcha/generated
SLIDE_CAPTCHA_TEMPORARY_URL_TTL=300
SLIDE_CAPTCHA_VALIDATE_MOVEMENT=true
SLIDE_CAPTCHA_MOVEMENT_MIN_POINTS=8
SLIDE_CAPTCHA_MOVEMENT_MIN_DURATION_MS=250
SLIDE_CAPTCHA_MOVEMENT_MAX_DURATION_MS=15000
SLIDE_CAPTCHA_MOVEMENT_MAX_SAME_Y_RATIO=0.9
Variáveis disponíveis
SLIDE_CAPTCHA_ENABLED
Ativa ou desativa o CAPTCHA. Use false apenas em ambientes controlados, como testes locais.
SLIDE_CAPTCHA_CACHE_STORE
Define o cache usado para armazenar desafios e tokens. Exemplo: redis. Se ficar vazio, usa o cache padrão do Laravel.
SLIDE_CAPTCHA_TTL
Tempo de validade do desafio, em segundos. Padrão: 120.
SLIDE_CAPTCHA_IMAGE_WIDTH
Largura da imagem do CAPTCHA. Padrão: 320.
SLIDE_CAPTCHA_IMAGE_HEIGHT
Altura da imagem do CAPTCHA. Padrão: 180.
SLIDE_CAPTCHA_PIECE_MIN_SIZE
Tamanho mínimo da peça recortada. Padrão: 42.
SLIDE_CAPTCHA_PIECE_MAX_SIZE
Tamanho máximo da peça recortada. Padrão: 58.
SLIDE_CAPTCHA_TOLERANCE
Margem de erro permitida, em pixels. Padrão: 8.
SLIDE_CAPTCHA_ROTATION_ENABLED
Ativa a rotação obrigatória da peça. Padrão: true.
Quando ativa, o encaixe no background aparece girado, a peça começa em 0° e o usuário precisa girá-la antes de verificar.
SLIDE_CAPTCHA_ROTATION_STEP_DEGREES
Quantidade de graus aplicada a cada clique nos botões de rotação. Padrão: 15.
SLIDE_CAPTCHA_ROTATION_MAX_DEGREES
Maior ângulo aleatório usado pelo desafio. Padrão: 90.
SLIDE_CAPTCHA_ROTATION_TOLERANCE_DEGREES
Margem de erro permitida para a rotação. Padrão: 8.
SLIDE_CAPTCHA_ROUTE_PREFIX
Prefixo das rotas internas do pacote. Padrão: slide-captcha.
SLIDE_CAPTCHA_MIDDLEWARE
Middlewares aplicados às rotas do CAPTCHA. Padrão: web.
SLIDE_CAPTCHA_STORAGE_DISK
Disco onde as imagens geradas serão salvas. Padrão: s3.
SLIDE_CAPTCHA_GENERATED_PATH
Pasta dentro do disco configurado onde as imagens temporárias serão salvas. Padrão: slide-captcha/generated.
SLIDE_CAPTCHA_TEMPORARY_URL_TTL
Tempo de validade das URLs temporárias das imagens, em segundos. Padrão: 300.
SLIDE_CAPTCHA_BACKGROUNDS_PATH
Diretório local usado para substituir as imagens base padrão do pacote.
Se esta variável não for definida, o pacote usa as imagens incluídas em:
vendor/codedart/laravel-slide-captcha/resources/backgrounds
Você pode usar um caminho absoluto:
SLIDE_CAPTCHA_BACKGROUNDS_PATH=/var/www/my-app/storage/app/captcha-backgrounds
Ou um caminho relativo à raiz do projeto Laravel:
SLIDE_CAPTCHA_BACKGROUNDS_PATH=storage/app/captcha-backgrounds
O diretório deve conter imagens .jpg, .jpeg, .png ou .webp.
SLIDE_CAPTCHA_VALIDATE_MOVEMENT
Ativa a análise básica do movimento do mouse ou toque. Padrão: true.
SLIDE_CAPTCHA_MOVEMENT_MIN_POINTS
Quantidade mínima de pontos de movimento enviados pelo navegador.
SLIDE_CAPTCHA_MOVEMENT_MIN_DURATION_MS
Duração mínima do movimento, em milissegundos.
SLIDE_CAPTCHA_MOVEMENT_MAX_DURATION_MS
Duração máxima do movimento, em milissegundos.
SLIDE_CAPTCHA_MOVEMENT_MAX_SAME_Y_RATIO
Proporção máxima permitida de movimentos com o mesmo eixo Y. Ajuda a rejeitar movimentos muito lineares.
Configuração do S3
Configure o disco s3 no .env da aplicação Laravel:
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
As imagens geradas são privadas. O navegador acessa essas imagens por URL temporária.
O usuário ou role da AWS precisa ter permissão para:
s3:PutObjects3:GetObjects3:DeleteObjects3:ListBucket, se exigido pela configuração do bucket
Publicar configuração
Não é obrigatório publicar a configuração.
Se quiser customizar o arquivo config/slide-captcha.php, rode:
php artisan vendor:publish --tag=slide-captcha-config
Depois limpe o cache de configuração:
php artisan config:clear
Também é possível publicar a view se quiser alterar o HTML do widget:
php artisan vendor:publish --tag=slide-captcha-views
Uso básico
Inclua o CAPTCHA no formulário Blade:
{{-- resources/views/contact.blade.php --}}
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Contato</title>
</head>
<body>
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<label>
Nome
<input type="text" name="name" value="{{ old('name') }}">
</label>
<label>
Mensagem
<textarea name="message">{{ old('message') }}</textarea>
</label>
@include('slide-captcha::captcha')
@error('slide_captcha_token')
<p>{{ $message }}</p>
@enderror
<button type="submit">Enviar</button>
</form>
</body>
</html>
O pacote adiciona os campos ocultos automaticamente:
slide_captcha_challenge_id
slide_captcha_token
slide_captcha_verified
No controller, valide o token:
<?php
namespace App\Http\Controllers;
use CodeDart\SlideCaptcha\Rules\SlideCaptchaVerified;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'message' => ['required', 'string', 'max:2000'],
'slide_captcha_token' => ['required', new SlideCaptchaVerified],
'slide_captcha_verified' => ['accepted'],
]);
// Envie e-mail, salve no banco ou execute sua lógica.
return back()->with('status', 'Mensagem enviada com sucesso.');
}
}
A regra SlideCaptchaVerified busca o token no cache e apaga o token após o uso. Isso impede reutilização.
Exemplo prático em um projeto real
Este exemplo cria uma página de contato protegida pelo CAPTCHA.
1. Rotas
Arquivo: routes/web.php
<?php
use App\Http\Controllers\ContactController;
use Illuminate\Support\Facades\Route;
Route::get('/contato', [ContactController::class, 'create'])->name('contact.create');
Route::post('/contato', [ContactController::class, 'store'])->name('contact.store');
2. Controller
Arquivo: app/Http/Controllers/ContactController.php
<?php
namespace App\Http\Controllers;
use CodeDart\SlideCaptcha\Rules\SlideCaptchaVerified;
use Illuminate\Http\Request;
class ContactController extends Controller
{
public function create()
{
return view('contact.create');
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:2000'],
'slide_captcha_token' => ['required', new SlideCaptchaVerified],
'slide_captcha_verified' => ['accepted'],
]);
// Aqui você pode salvar no banco, enviar e-mail ou criar um ticket.
return redirect()
->route('contact.create')
->with('status', 'Mensagem enviada com sucesso.');
}
}
3. View
Arquivo: resources/views/contact/create.blade.php
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Contato</title>
</head>
<body>
@if (session('status'))
<p>{{ session('status') }}</p>
@endif
<form method="POST" action="{{ route('contact.store') }}">
@csrf
<div>
<label for="name">Nome</label>
<input id="name" name="name" type="text" value="{{ old('name') }}">
@error('name') <p>{{ $message }}</p> @enderror
</div>
<div>
<label for="email">E-mail</label>
<input id="email" name="email" type="email" value="{{ old('email') }}">
@error('email') <p>{{ $message }}</p> @enderror
</div>
<div>
<label for="message">Mensagem</label>
<textarea id="message" name="message">{{ old('message') }}</textarea>
@error('message') <p>{{ $message }}</p> @enderror
</div>
@include('slide-captcha::captcha')
@error('slide_captcha_token')
<p>{{ $message }}</p>
@enderror
@error('slide_captcha_verified')
<p>Confirme o CAPTCHA antes de enviar.</p>
@enderror
<button type="submit">Enviar</button>
</form>
</body>
</html>
O que acontece neste fluxo
- O usuário abre
/contato. - A view renderiza o CAPTCHA.
- O JavaScript chama
GET /slide-captcha/new. - O pacote gera o desafio e salva as imagens temporárias no S3.
- O navegador recebe URLs temporárias para ver as imagens.
- O usuário arrasta a peça e, quando a rotação estiver ativa, gira a peça até encaixar.
- O JavaScript chama
POST /slide-captcha/verify. - Se estiver correto, o pacote retorna um token.
- O formulário envia
slide_captcha_token. - O controller valida o token com
SlideCaptchaVerified.
Tratamento de erros
Erro: CAPTCHA não carrega
Verifique se as rotas existem:
php artisan route:list
Procure por:
slide-captcha.new
slide-captcha.verify
slide-captcha.asset
Se não aparecerem, limpe caches:
php artisan optimize:clear
Erro: CSRF token mismatch
O JavaScript envia o CSRF usando a meta tag:
<meta name="csrf-token" content="{{ csrf_token() }}">
Garanta que essa tag existe no <head> da página.
Erro: disco não suporta URL temporária
O pacote precisa gerar URLs temporárias para as imagens.
Use um disco S3:
SLIDE_CAPTCHA_STORAGE_DISK=s3
E confirme se o S3 está configurado corretamente no Laravel.
Erro: nenhuma imagem base encontrada
Mensagem comum:
Nenhuma imagem base foi encontrada
Causas prováveis:
SLIDE_CAPTCHA_BACKGROUNDS_PATHaponta para uma pasta inexistente.- A pasta existe, mas não tem imagens.
- As imagens não são
.jpg,.jpeg,.pngou.webp.
Solução:
SLIDE_CAPTCHA_BACKGROUNDS_PATH=storage/app/captcha-backgrounds
Depois coloque imagens nessa pasta.
Erro: validação do CAPTCHA inválida ou expirada
Mensagem:
A validação do CAPTCHA é inválida ou expirou.
Causas prováveis:
- O usuário demorou demais para enviar.
- O token já foi usado.
- O cache foi limpo.
- O cache configurado na geração é diferente do cache usado na validação.
Solução:
- Use Redis em produção.
- Aumente
SLIDE_CAPTCHA_TTLse necessário. - Não reutilize o mesmo token em mais de um submit.
Respostas do endpoint de verificação
O endpoint POST /slide-captcha/verify pode retornar:
{
"success": false,
"reason": "invalid_position",
"message": "A posição enviada não confere com o desafio."
}
Motivos comuns:
validation_failed: dados enviados inválidos.not_found: desafio expirado ou inexistente.used: desafio já usado.expired: desafio expirado.invalid_position: usuário errou a posição.invalid_rotation: usuário errou a rotação da peça.movement_too_short: poucos pontos de movimento.movement_too_fast: movimento rápido demais.movement_too_slow: movimento lento demais.movement_too_linear: movimento muito linear.
Boas práticas
- Use
SLIDE_CAPTCHA_STORAGE_DISK=s3em produção. - Mantenha as imagens geradas privadas.
- Use URLs temporárias com TTL curto.
- Use Redis para cache.
- Não valide apenas
slide_captcha_verified; valide sempreslide_captcha_tokencomSlideCaptchaVerified. - Coloque a validação no controller ou em um Form Request.
- Mantenha as imagens base sem texto, logos ou rostos identificáveis.
- Use imagens com detalhes distribuídos para facilitar o encaixe da peça.
- Não use imagens muito lisas ou muito escuras.
- Em produção, monitore erros de S3 e cache.
- Evite desativar
SLIDE_CAPTCHA_VALIDATE_MOVEMENTem produção.
Responsabilidades recomendadas:
routes/web.php: define as rotas do formulário da sua aplicação.Controller: valida o formulário e aplicaSlideCaptchaVerified.Blade: renderiza o formulário e inclui@include('slide-captcha::captcha')..env: configura cache, S3 e comportamento do CAPTCHA.- Diretório de backgrounds: guarda as imagens base customizadas, se você não quiser usar as imagens padrão do pacote.
Problemas comuns
O botão enviar não valida mesmo após acertar o CAPTCHA
Causa provável: o campo slide_captcha_token não chegou no request.
Solução: confira se o CAPTCHA está dentro da tag <form>.
O JavaScript não carrega
Causa provável: rota de asset não registrada ou cache antigo de rotas.
Solução:
php artisan optimize:clear
php artisan route:list
A imagem aparece quebrada
Causa provável: a URL temporária expirou, o S3 está sem permissão ou o disco está mal configurado.
Solução: confira as credenciais AWS e aumente temporariamente:
SLIDE_CAPTCHA_TEMPORARY_URL_TTL=600
O CAPTCHA sempre retorna erro de movimento
Causa provável: ambiente de teste automatizado ou navegador bloqueando eventos.
Solução para desenvolvimento:
SLIDE_CAPTCHA_VALIDATE_MOVEMENT=false
Não é recomendado deixar isso desativado em produção.
O pacote usa as imagens padrão em vez das minhas
Causa provável: SLIDE_CAPTCHA_BACKGROUNDS_PATH não foi definido ou está errado.
Solução:
SLIDE_CAPTCHA_BACKGROUNDS_PATH=storage/app/captcha-backgrounds
Depois rode:
php artisan config:clear
A configuração do .env não muda o comportamento
Causa provável: configuração cacheada.
Solução:
php artisan config:clear
php artisan cache:clear
Exemplo final completo
Este exemplo funciona em um projeto Laravel limpo, desde que o pacote esteja instalado e o disco S3 esteja configurado.
.env
APP_URL=http://localhost
CACHE_STORE=redis
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket
AWS_URL=
AWS_ENDPOINT=
AWS_USE_PATH_STYLE_ENDPOINT=false
SLIDE_CAPTCHA_ENABLED=true
SLIDE_CAPTCHA_CACHE_STORE=redis
SLIDE_CAPTCHA_STORAGE_DISK=s3
SLIDE_CAPTCHA_GENERATED_PATH=slide-captcha/generated
SLIDE_CAPTCHA_TEMPORARY_URL_TTL=300
SLIDE_CAPTCHA_TTL=120
SLIDE_CAPTCHA_TOLERANCE=8
SLIDE_CAPTCHA_ROTATION_ENABLED=true
SLIDE_CAPTCHA_ROTATION_STEP_DEGREES=15
SLIDE_CAPTCHA_ROTATION_MAX_DEGREES=90
SLIDE_CAPTCHA_ROTATION_TOLERANCE_DEGREES=8
SLIDE_CAPTCHA_VALIDATE_MOVEMENT=true
Em projetos Laravel mais antigos, a variável do cache padrão pode se chamar CACHE_DRIVER em vez de CACHE_STORE. Use o nome adotado pela sua aplicação.
routes/web.php
<?php
use App\Http\Controllers\RegisterInterestController;
use Illuminate\Support\Facades\Route;
Route::get('/interesse', [RegisterInterestController::class, 'create'])
->name('interest.create');
Route::post('/interesse', [RegisterInterestController::class, 'store'])
->name('interest.store');
app/Http/Controllers/RegisterInterestController.php
<?php
namespace App\Http\Controllers;
use CodeDart\SlideCaptcha\Rules\SlideCaptchaVerified;
use Illuminate\Http\Request;
class RegisterInterestController extends Controller
{
public function create()
{
return view('interest.create');
}
public function store(Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'slide_captcha_token' => ['required', new SlideCaptchaVerified],
'slide_captcha_verified' => ['accepted'],
]);
// Exemplo simples: aqui você salvaria $data no banco.
return redirect()
->route('interest.create')
->with('status', 'Cadastro recebido com sucesso.');
}
}
resources/views/interest/create.blade.php
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Tenho interesse</title>
</head>
<body>
<h1>Tenho interesse</h1>
@if (session('status'))
<p>{{ session('status') }}</p>
@endif
<form method="POST" action="{{ route('interest.store') }}">
@csrf
<div>
<label for="name">Nome</label>
<input id="name" type="text" name="name" value="{{ old('name') }}">
@error('name') <p>{{ $message }}</p> @enderror
</div>
<div>
<label for="email">E-mail</label>
<input id="email" type="email" name="email" value="{{ old('email') }}">
@error('email') <p>{{ $message }}</p> @enderror
</div>
@include('slide-captcha::captcha')
@error('slide_captcha_token')
<p>{{ $message }}</p>
@enderror
@error('slide_captcha_verified')
<p>Resolva o CAPTCHA antes de enviar.</p>
@enderror
<button type="submit">Enviar</button>
</form>
</body>
</html>
Teste rápido
Rode a aplicação:
php artisan serve
Acesse:
http://localhost:8000/interesse
Resolva o CAPTCHA e envie o formulário.
Se algo falhar, rode:
php artisan optimize:clear
php artisan route:list
E confira as configurações de cache e S3 no .env.