A simple, self-hosted math-based CAPTCHA package for Laravel with image generation to prevent AI/OCR bypass. No external API dependencies required.
Note: This package was generated with the assistance of AI (Claude by Anthropic).
- Screenshot
- Features
- Requirements
- Installation
- Quick Start
- Usage
- Frontend Integration
- Configuration
- API Reference
- Security Considerations
- Troubleshooting
- Contributing
- License
Example of a generated math CAPTCHA image with noise and distortion
- Math-based CAPTCHA - Generates simple arithmetic problems (addition, subtraction, multiplication)
- Image Generation - Renders CAPTCHA as PNG image with anti-OCR measures:
- Random noise lines and dots
- Character position variations
- Multiple text colors
- Curved distortion line
- Self-hosted - No external API calls, no third-party dependencies
- Configurable - Customize difficulty, appearance, and behavior
- Laravel Integration - Service provider auto-discovery, Facade support, validation rule
- One-time Use - Each CAPTCHA token is invalidated after verification attempt
- Cache-based Storage - Uses Laravel's cache system for token storage
- PHP 8.2 or higher
- Laravel 11.x or 12.x
- GD extension (for image generation)
Install the package via Composer:
composer require solution-forest/laravel-math-captchaThe package will automatically register its service provider via Laravel's package auto-discovery.
To customize the CAPTCHA settings, publish the configuration file:
php artisan vendor:publish --tag=math-captcha-configThis will create a config/math-captcha.php file in your application.
-
Install the package:
composer require solution-forest/laravel-math-captcha
-
Add CAPTCHA to your form (frontend):
<img id="captcha-image" src="" alt="CAPTCHA"> <button type="button" onclick="refreshCaptcha()">Refresh</button> <input type="hidden" name="captcha_token" id="captcha-token"> <input type="text" name="captcha_answer" placeholder="Enter the answer"> <script> async function refreshCaptcha() { const response = await fetch('/captcha'); const data = await response.json(); document.getElementById('captcha-image').src = data.image; document.getElementById('captcha-token').value = data.token; } refreshCaptcha(); // Load initial CAPTCHA </script>
-
Verify in your controller (backend):
use SolutionForest\MathCaptcha\Contracts\CaptchaGenerator; public function store(Request $request, CaptchaGenerator $captcha) { if (!$captcha->verify($request->captcha_token, $request->captcha_answer)) { return back()->withErrors(['captcha' => 'Incorrect answer']); } // Process form... }
The package automatically registers a route at /captcha (GET) that returns JSON:
{
"token": "abc123def456...",
"image": "data:image/png;base64,iVBORw0KGgo..."
}token- A unique 32-character string to identify this CAPTCHA challengeimage- Base64-encoded PNG image data URI that can be used directly in<img src="">
Send the token and user's answer to your backend for verification. The verification:
- Returns
trueif the answer is correct - Returns
falseif the answer is wrong or the token is invalid/expired - Invalidates the token after the first verification attempt (one-time use)
use SolutionForest\MathCaptcha\Facades\MathCaptcha;
// Generate a new CAPTCHA
$captcha = MathCaptcha::generate();
// Returns: [
// 'token' => 'abc123...',
// 'image' => 'data:image/png;base64,...'
// ]
// Verify an answer
$isValid = MathCaptcha::verify($token, $userAnswer);
// Returns: true or falseuse SolutionForest\MathCaptcha\Contracts\CaptchaGenerator;
class ContactController extends Controller
{
public function store(Request $request, CaptchaGenerator $captcha)
{
// Verify the CAPTCHA
$isValid = $captcha->verify(
$request->input('captcha_token'),
$request->input('captcha_answer')
);
if (!$isValid) {
return back()->withErrors(['captcha' => 'Invalid CAPTCHA answer. Please try again.']);
}
// CAPTCHA is valid, process the form...
}
}For cleaner validation, use the built-in validation rule:
use SolutionForest\MathCaptcha\Rules\ValidCaptcha;
public function store(Request $request)
{
$request->validate([
'name' => 'required|string',
'email' => 'required|email',
'captcha_answer' => ['required', new ValidCaptcha()],
]);
// Validation passed, process the form...
}The rule automatically looks for captcha_token and captcha_answer fields. You can customize the field names:
new ValidCaptcha(
tokenField: 'my_captcha_token',
answerField: 'my_captcha_answer'
)Livewire Component (app/Livewire/ContactForm.php):
<?php
namespace App\Livewire;
use Livewire\Component;
use SolutionForest\MathCaptcha\Contracts\CaptchaGenerator;
class ContactForm extends Component
{
public string $name = '';
public string $email = '';
public string $phone = '';
public string $message = '';
public string $captchaToken = '';
public string $captchaImage = '';
public string $captchaAnswer = '';
public function mount(): void
{
$this->refreshCaptcha();
}
public function refreshCaptcha(): void
{
$captcha = app(CaptchaGenerator::class)->generate();
$this->captchaToken = $captcha['token'];
$this->captchaImage = $captcha['image'];
$this->captchaAnswer = '';
}
public function submit(CaptchaGenerator $captcha): void
{
$this->validate([
'name' => 'required|string|max:255',
'email' => 'required|email',
'phone' => 'required|string',
'captchaAnswer' => 'required',
]);
// Verify CAPTCHA
if (!$captcha->verify($this->captchaToken, $this->captchaAnswer)) {
$this->addError('captchaAnswer', 'Incorrect answer. Please try again.');
$this->refreshCaptcha();
return;
}
// Process form submission...
session()->flash('success', 'Your message has been sent!');
$this->reset(['name', 'email', 'phone', 'message', 'captchaAnswer']);
$this->refreshCaptcha();
}
public function render()
{
return view('livewire.contact-form');
}
}Blade View (resources/views/livewire/contact-form.blade.php):
<form wire:submit="submit">
@if (session('success'))
<div class="alert alert-success">{{ session('success') }}</div>
@endif
<div>
<label for="name">Name</label>
<input type="text" id="name" wire:model="name">
@error('name') <span class="error">{{ $message }}</span> @enderror
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" wire:model="email">
@error('email') <span class="error">{{ $message }}</span> @enderror
</div>
<div>
<label for="phone">Phone</label>
<input type="tel" id="phone" wire:model="phone">
@error('phone') <span class="error">{{ $message }}</span> @enderror
</div>
<div>
<label for="message">Message</label>
<textarea id="message" wire:model="message"></textarea>
</div>
<div>
<label>Security Check</label>
<div class="captcha-container">
<img src="{{ $captchaImage }}" alt="CAPTCHA">
<button type="button" wire:click="refreshCaptcha" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="refreshCaptcha">Refresh</span>
<span wire:loading wire:target="refreshCaptcha">Loading...</span>
</button>
</div>
<input
type="text"
wire:model="captchaAnswer"
placeholder="Enter the answer"
inputmode="numeric"
>
@error('captchaAnswer') <span class="error">{{ $message }}</span> @enderror
</div>
<button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Submit</span>
<span wire:loading>Sending...</span>
</button>
</form>import { useState, useEffect, useCallback } from 'react';
interface CaptchaData {
token: string;
image: string;
}
function ContactForm() {
const [captcha, setCaptcha] = useState<CaptchaData | null>(null);
const [answer, setAnswer] = useState('');
const [isLoading, setIsLoading] = useState(false);
const fetchCaptcha = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch('/captcha');
const data = await response.json();
setCaptcha(data);
setAnswer('');
} catch (error) {
console.error('Failed to fetch CAPTCHA:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchCaptcha();
}, [fetchCaptcha]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
formData.append('captcha_token', captcha?.token || '');
formData.append('captcha_answer', answer);
// Submit form...
};
return (
<form onSubmit={handleSubmit}>
{/* Other form fields... */}
<div className="captcha-container">
<div className="captcha-image">
{isLoading ? (
<span>Loading...</span>
) : (
<img
src={captcha?.image}
alt="CAPTCHA"
draggable={false}
/>
)}
</div>
<button type="button" onClick={fetchCaptcha} disabled={isLoading}>
Refresh
</button>
</div>
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Enter the answer"
inputMode="numeric"
required
/>
<button type="submit">Submit</button>
</form>
);
}<template>
<form @submit.prevent="handleSubmit">
<!-- Other form fields... -->
<div class="captcha-container">
<div class="captcha-image">
<span v-if="isLoading">Loading...</span>
<img v-else :src="captcha?.image" alt="CAPTCHA" />
</div>
<button type="button" @click="fetchCaptcha" :disabled="isLoading">
Refresh
</button>
</div>
<input
v-model="answer"
type="text"
placeholder="Enter the answer"
inputmode="numeric"
required
/>
<button type="submit">Submit</button>
</form>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const captcha = ref(null);
const answer = ref('');
const isLoading = ref(false);
async function fetchCaptcha() {
isLoading.value = true;
try {
const response = await fetch('/captcha');
captcha.value = await response.json();
answer.value = '';
} catch (error) {
console.error('Failed to fetch CAPTCHA:', error);
} finally {
isLoading.value = false;
}
}
async function handleSubmit() {
const formData = {
// Other form data...
captcha_token: captcha.value?.token,
captcha_answer: answer.value,
};
// Submit form...
}
onMounted(fetchCaptcha);
</script><form id="contact-form">
<!-- Other form fields... -->
<div class="captcha-container">
<img id="captcha-image" src="" alt="CAPTCHA">
<button type="button" id="refresh-captcha">Refresh</button>
</div>
<input type="hidden" name="captcha_token" id="captcha-token">
<input
type="text"
name="captcha_answer"
id="captcha-answer"
placeholder="Enter the answer"
inputmode="numeric"
required
>
<button type="submit">Submit</button>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
const captchaImage = document.getElementById('captcha-image');
const captchaToken = document.getElementById('captcha-token');
const captchaAnswer = document.getElementById('captcha-answer');
const refreshButton = document.getElementById('refresh-captcha');
async function fetchCaptcha() {
try {
const response = await fetch('/captcha');
const data = await response.json();
captchaImage.src = data.image;
captchaToken.value = data.token;
captchaAnswer.value = '';
} catch (error) {
console.error('Failed to fetch CAPTCHA:', error);
}
}
refreshButton.addEventListener('click', fetchCaptcha);
// Load initial CAPTCHA
fetchCaptcha();
});
</script>After publishing the configuration file, you can customize the following options in config/math-captcha.php:
// Image dimensions in pixels
'width' => 200,
'height' => 60,
// Built-in GD font size (1-5, where 5 is largest)
'font_size' => 5,// How long a CAPTCHA token remains valid (in minutes)
'ttl' => 10,
// Math operators to use: '+' (addition), '-' (subtraction), '*' (multiplication)
'operators' => ['+', '-', '*'],
// Number ranges for each operator to keep problems reasonable
// For subtraction, num1 is always >= num2 to avoid negative answers
'ranges' => [
'+' => ['min1' => 1, 'max1' => 50, 'min2' => 1, 'max2' => 50], // Results: 2-100
'-' => ['min1' => 20, 'max1' => 50, 'min2' => 1, 'max2' => 20], // Results: 0-49
'*' => ['min1' => 2, 'max1' => 12, 'min2' => 2, 'max2' => 9], // Results: 4-108
],// Background color [R, G, B]
'background_color' => [245, 245, 247],
// Text colors (randomly selected per character) [R, G, B]
'text_colors' => [
[30, 30, 50], // Dark blue-gray
[50, 30, 80], // Purple-gray
[30, 60, 60], // Teal-gray
],
// Noise element colors [R, G, B]
'noise_colors' => [
[200, 200, 210],
[180, 190, 200],
[210, 200, 190],
],
// Number of noise lines to draw
'noise_lines' => 8,
// Number of noise dots to draw
'noise_dots' => 100,'route' => [
// Set to false to disable automatic route registration
'enabled' => true,
// The URI for the CAPTCHA endpoint
'uri' => '/captcha',
// Route name for URL generation: route('captcha.generate')
'name' => 'captcha.generate',
// Middleware to apply to the route
'middleware' => ['web'],
],// Prefix for cache keys (useful if you have multiple apps sharing cache)
'cache_prefix' => 'math_captcha',interface CaptchaGenerator
{
/**
* Generate a new CAPTCHA challenge.
*
* @return array{token: string, image: string}
*/
public function generate(): array;
/**
* Verify a CAPTCHA answer.
*
* @param string $token The CAPTCHA token
* @param int|string $answer The user's answer
* @return bool True if correct, false otherwise
*/
public function verify(string $token, int|string $answer): bool;
}use SolutionForest\MathCaptcha\Rules\ValidCaptcha;
// Default field names
new ValidCaptcha();
// Custom field names
new ValidCaptcha(
tokenField: 'my_token_field',
answerField: 'my_answer_field'
);-
One-time Use: Each CAPTCHA token is invalidated after the first verification attempt, preventing replay attacks.
-
Token Expiration: Tokens expire after a configurable time (default: 10 minutes), limiting the window for brute-force attacks.
-
Rate Limiting: Consider adding rate limiting to your CAPTCHA endpoint to prevent abuse:
// In your route configuration or middleware Route::middleware(['throttle:60,1'])->get('/captcha', ...);
-
HTTPS: Always serve your application over HTTPS to prevent CAPTCHA images and tokens from being intercepted.
-
Cache Security: CAPTCHA answers are stored in your application's cache. Ensure your cache driver is properly secured.
Problem: The image displays garbled characters like "A" instead of math symbols.
Solution: This package uses PHP's built-in GD fonts which only support ASCII characters. The operators are displayed as:
- Addition:
+ - Subtraction:
- - Multiplication:
x(lowercase x)
Problem: Error "Call to undefined function imagecreatetruecolor()"
Solution: Install the GD extension:
# Ubuntu/Debian
sudo apt-get install php-gd
# macOS with Homebrew
brew install php-gd
# Then restart your web serverProblem: Correct answers are rejected.
Possible Causes:
- Token expired (default: 10 minutes)
- Token already used (one-time use)
- Cache not working properly
Solution: Check your cache configuration and ensure the cache driver is working correctly.
Problem: 404 error when accessing /captcha
Solution:
- Clear route cache:
php artisan route:clear - Check if route registration is enabled in config
- Verify the package is properly installed:
composer dump-autoload
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This package is open-sourced software licensed under the MIT License.
Made with AI assistance by Solution Forest
