laravel-email-verifier maintained by willvincent
Laravel Email Verifier
A comprehensive email verification package for Laravel 11/12 that validates email addresses through multiple layers of checks including format validation, domain sanity, MX records, disposable domain detection, and optional integration with external email verification providers.
Features
- Multi-layered Validation: Format, domain sanity, MX records, disposable domains, role-based addresses, plus addressing
- Score-based System: Each email receives a quality score (0-100) based on multiple checks
- External Provider Support: Optional integration with 8 major email verification APIs
- Abstract API
- Bouncer
- Emailable
- Kickbox
- NeverBounce
- QuickEmailVerification
- VerifiedEmail
- ZeroBounce
- Fail-Open Design: External provider failures don't block email validation
- Configurable Rules: Enable/disable specific validation rules
- Laravel Validation Integration: Use as custom validation rule or extension
- Artisan Command: Fetch and update disposable domain lists
- Fully Typed: 100% type coverage with strict types
- Well Tested: 98.8% test coverage with 125 passing tests
Requirements
- PHP 8.2+
- Laravel 11.x or 12.x
Installation
composer require willvincent/laravel-email-verifier
Publish Configuration
php artisan vendor:publish --tag=email-verifier-config
Publish Translations (Optional)
php artisan vendor:publish --tag=email-verifier-lang
Configuration
The package comes with sensible defaults. Key configuration options in config/email-verifier.php:
return [
// Minimum acceptable score (0-100)
'min_score' => env('EMAIL_VERIFIER_MIN_SCORE', 70),
// Require MX records (strict mode)
'mx_strict' => env('EMAIL_VERIFIER_MX_STRICT', true),
// Normalization settings
'normalize' => [
'enabled' => true,
'lowercase_local' => false, // Most providers are case-sensitive
],
// Disposable domain detection
'disposable' => [
'file' => storage_path('app/disposable_email_domains.txt'),
'source_url' => 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt',
'extra_domains' => [],
'timeout_seconds' => 10,
'max_bytes' => 2_000_000,
],
// External verification provider
'external' => [
'driver' => env('EMAIL_VERIFIER_EXTERNAL_DRIVER'), // abstract, bouncer, emailable, kickbox, neverbounce, quickemailverification, verifiedemail, zerobounce
'timeout_seconds' => 5,
'bouncer' => [
'api_key' => env('BOUNCER_API_KEY'),
'endpoint' => 'https://api.usebouncer.com/v1.1/email/verify',
],
// ... other providers
],
];
Usage
Basic Usage
use WillVincent\EmailVerifier\Facades\EmailVerifier;
$result = EmailVerifier::verify('user@example.com');
if ($result->accepted) {
echo "Email is valid! Score: {$result->score}";
} else {
echo "Email rejected: " . implode(', ', $result->reasons);
}
As Validation Rule (Object Style)
use WillVincent\EmailVerifier\Validation\VerifiedEmail;
$request->validate([
'email' => ['required', new VerifiedEmail()],
]);
// With custom minimum score
$request->validate([
'email' => ['required', new VerifiedEmail(minScore: 90)],
]);
// Disable external verification for this validation
$request->validate([
'email' => ['required', new VerifiedEmail(allowExternal: false)],
]);
As Validation Rule (String Style)
$request->validate([
'email' => 'required|verified_email',
]);
// With minimum score
$request->validate([
'email' => 'required|verified_email:90',
]);
// With minimum score and no external verification
$request->validate([
'email' => 'required|verified_email:90,no_external',
]);
Understanding Results
$result = EmailVerifier::verify('info@example.com');
// Core properties
$result->accepted; // bool: Overall pass/fail
$result->score; // int: Quality score (0-100)
$result->normalizedEmail; // string: Normalized email address
$result->reasons; // array: Reasons for score reduction/rejection
$result->meta; // array: Additional metadata
// Common reasons
// - invalid_format
// - invalid_domain
// - no_mx_records
// - disposable_domain
// - role_based_local_part (info@, admin@, etc.)
// - plus_addressing (user+tag@)
// - external_rejected:*
Scoring System
- 100: Perfect email (valid format, good domain, MX records exist)
- 95: Plus addressing detected (user+tag@domain.com)
- 85: Role-based address (info@, admin@, support@)
- 85: Catch-all domain (external provider detected)
- 80: Unknown status from external provider
- 75: Risky (external provider flagged)
- 0: Hard rejection (invalid format, disposable, no MX in strict mode)
External Providers
Setup Example (Kickbox)
- Sign up at Kickbox
- Add to
.env:
EMAIL_VERIFIER_EXTERNAL_DRIVER=kickbox
KICKBOX_API_KEY=your_api_key_here
- The package will automatically use Kickbox for additional verification
Supported Providers
All providers follow the same pattern:
# Abstract
EMAIL_VERIFIER_EXTERNAL_DRIVER=abstract
ABSTRACT_API_KEY=your_key
# Bouncer
EMAIL_VERIFIER_EXTERNAL_DRIVER=bouncer
BOUNCER_API_KEY=your_key
# Emailable
EMAIL_VERIFIER_EXTERNAL_DRIVER=emailable
EMAILABLE_API_KEY=your_key
# Kickbox
EMAIL_VERIFIER_EXTERNAL_DRIVER=kickbox
KICKBOX_API_KEY=your_key
# NeverBounce
EMAIL_VERIFIER_EXTERNAL_DRIVER=neverbounce
NEVERBOUNCE_API_KEY=your_key
# QuickEmailVerification
EMAIL_VERIFIER_EXTERNAL_DRIVER=quickemailverification
QUICKEMAILVERIFICATION_API_KEY=your_key
# VerifiedEmail
EMAIL_VERIFIER_EXTERNAL_DRIVER=verifiedemail
VERIFIEDEMAIL_API_KEY=your_key
# ZeroBounce
EMAIL_VERIFIER_EXTERNAL_DRIVER=zerobounce
ZEROBOUNCE_API_KEY=your_key
Creating a Custom Provider
You can create your own external verification driver by implementing the ExternalEmailVerifier interface:
namespace App\EmailVerification;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Http\Client\Factory as HttpFactory;
use WillVincent\EmailVerifier\Contracts\ExternalEmailVerifier;
use WillVincent\EmailVerifier\Results\EmailVerificationResult;
class CustomEmailVerifier implements ExternalEmailVerifier
{
public function __construct(
private HttpFactory $http,
private ConfigRepository $config,
) {}
public function verify(string $email): EmailVerificationResult
{
$apiKey = $this->config->get('email-verifier.external.custom.api_key', '');
$endpoint = $this->config->get('email-verifier.external.custom.endpoint', '');
$timeout = $this->config->get('email-verifier.external.timeout_seconds', 5);
// Return accepted with reduced score if not configured
if ($apiKey === '' || $endpoint === '') {
return new EmailVerificationResult(
accepted: true,
score: 100,
normalizedEmail: $email,
reasons: [],
meta: ['provider' => 'custom', 'configured' => false],
);
}
try {
$response = $this->http
->timeout($timeout)
->retry(1, 250)
->post($endpoint, [
'email' => $email,
'api_key' => $apiKey,
]);
if (!$response->ok()) {
// Fail-open: accept with reduced score
return new EmailVerificationResult(
accepted: true,
score: 90,
normalizedEmail: $email,
reasons: ['external_provider_unavailable'],
meta: ['provider' => 'custom', 'http_status' => $response->status()],
);
}
$data = $response->json() ?? [];
$status = $data['status'] ?? 'unknown';
// Map provider status to scores
return match ($status) {
'valid' => new EmailVerificationResult(
accepted: true,
score: 100,
normalizedEmail: $email,
meta: ['provider' => 'custom', 'status' => $status],
),
'invalid' => new EmailVerificationResult(
accepted: false,
score: 0,
normalizedEmail: $email,
reasons: ['external_rejected:invalid'],
meta: ['provider' => 'custom', 'status' => $status],
),
'risky' => new EmailVerificationResult(
accepted: true,
score: 75,
normalizedEmail: $email,
reasons: ['external_risky'],
meta: ['provider' => 'custom', 'status' => $status],
),
default => new EmailVerificationResult(
accepted: true,
score: 80,
normalizedEmail: $email,
reasons: ['external_unknown'],
meta: ['provider' => 'custom', 'status' => $status],
),
};
} catch (\Throwable $e) {
// Fail-open on exceptions
return new EmailVerificationResult(
accepted: true,
score: 90,
normalizedEmail: $email,
reasons: ['external_exception'],
meta: ['provider' => 'custom', 'error' => $e->getMessage()],
);
}
}
}
Register your custom driver in a service provider:
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use WillVincent\EmailVerifier\External\ExternalEmailVerifierManager;
use App\EmailVerification\CustomEmailVerifier;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->app->extend(ExternalEmailVerifierManager::class, function ($manager, $app) {
$manager->extend('custom', function () use ($app) {
return $app->make(CustomEmailVerifier::class);
});
return $manager;
});
}
}
Configure in config/email-verifier.php:
'external' => [
'driver' => env('EMAIL_VERIFIER_EXTERNAL_DRIVER'), // 'custom'
'custom' => [
'api_key' => env('CUSTOM_API_KEY'),
'endpoint' => env('CUSTOM_ENDPOINT', 'https://api.example.com/verify'),
],
],
Then set in .env:
EMAIL_VERIFIER_EXTERNAL_DRIVER=custom
CUSTOM_API_KEY=your_api_key
CUSTOM_ENDPOINT=https://api.example.com/verify
Best Practices for Custom Drivers:
- Fail-Open Design: Always return
accepted: trueon errors/timeouts with a reduced score (80-90) - Scoring: Use 100 for valid, 75 for risky, 80 for unknown, 0 for hard rejections
- Meta Data: Include provider name, status, and raw response for debugging
- Timeouts: Respect the
email-verifier.external.timeout_secondsconfig - Retries: Use
retry(1, 250)for transient failures - Configuration: Check if API key/endpoint are configured before making requests
Provider Behavior
- External providers are optional and only called after local checks pass
- Failures are fail-open (provider unavailable = accept with lower score)
- Results are merged with local validation scores
- Custom endpoints can be configured for all providers
Disposable Domain Detection
Update Disposable Domains List
php artisan email-verifier:fetch-disposable-domains
Options:
# Custom source URL
php artisan email-verifier:fetch-disposable-domains --url=https://example.com/domains.txt
# Custom output path
php artisan email-verifier:fetch-disposable-domains --path=/custom/path.txt
# Force update even if unchanged
php artisan email-verifier:fetch-disposable-domains --force
Add Custom Disposable Domains
In config/email-verifier.php:
'disposable' => [
'extra_domains' => [
'tempmail.com',
'throwaway.email',
],
],
Advanced Usage
Dependency Injection
use WillVincent\EmailVerifier\Contracts\EmailVerifierContract;
class UserController extends Controller
{
public function __construct(
private EmailVerifierContract $verifier
) {}
public function store(Request $request)
{
$result = $this->verifier->verify($request->email);
if ($result->score < 90) {
return back()->withErrors([
'email' => 'Please provide a high-quality email address.'
]);
}
// Proceed with user registration
}
}
Custom Validation Messages
In resources/lang/en/validation.php:
'custom' => [
'email' => [
'verified_email' => 'The :attribute address appears to be invalid or temporary.',
],
],
Or publish and edit the package translations:
php artisan vendor:publish --tag=email-verifier-lang
Testing
# Run tests
composer test
# Run tests with coverage
composer test-coverage
# Type coverage
composer type-coverage
# Static analysis
composer phpstan
# Code style check
composer pint-test
Architecture
Validation Flow
- Format Check: RFC 5322 validation
- Domain Sanity: Check for valid TLD, no leading/trailing dots
- Normalization: Lowercase domain, optionally lowercase local part
- MX Records: Verify domain has mail servers
- Disposable Detection: Check against known disposable domains
- Role-Based Detection: Flag generic addresses (admin@, info@)
- Plus Addressing: Detect and flag plus addressing
- Score Check: Reject if score below threshold
- External Verification (optional): Verify with third-party API
- Final Score Check: Apply threshold after external verification
Chain of Responsibility Pattern
Each validation rule is independent and can modify the result:
interface Rule
{
public function apply(
VerificationContext $ctx,
EmailVerificationResult $result
): void;
}
Rules can:
- Reject the email (
$result->accepted = false) - Reduce the score (
$result->score -= 15) - Add reasons (
$result->addReason('...')) - Add metadata (
$result->meta['key'] = 'value')
Performance
Latency Characteristics
- Local checks: < 1ms (format, domain, disposable)
- MX lookup: 10-50ms (DNS query)
- External provider: 100-500ms (HTTP request)
- Total (with external): ~150-600ms per email
Performance Recommendations
1. Use Queue-Based Verification for User Registration
For the best user experience during registration, validate emails asynchronously:
use Illuminate\Support\Facades\Queue;
use WillVincent\EmailVerifier\Facades\EmailVerifier;
class RegisterController extends Controller
{
public function store(Request $request)
{
// Quick validation without external provider (< 50ms)
$request->validate([
'email' => ['required', 'email', new VerifiedEmail(allowExternal: false)],
]);
// Create user with pending status
$user = User::create([
'email' => $request->email,
'email_verified_at' => null,
]);
// Run full verification in background
Queue::push(new VerifyUserEmailJob($user));
return redirect()->route('verify-email-notice');
}
}
Job implementation:
namespace App\Jobs;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use WillVincent\EmailVerifier\Facades\EmailVerifier;
class VerifyUserEmailJob implements ShouldQueue
{
use Queueable;
public function __construct(
public User $user
) {}
public function handle(): void
{
// Full verification with external provider
$result = EmailVerifier::verify($this->user->email);
if ($result->accepted && $result->score >= 80) {
// Email looks good - allow user to proceed
$this->user->update([
'email_verification_score' => $result->score,
]);
} else {
// Email suspicious - require additional verification
$this->user->update([
'email_verification_score' => $result->score,
'requires_manual_review' => true,
]);
// Optionally notify admins
}
}
}
2. Disable External Verification in Synchronous Validation
For form requests that need immediate responses, disable external verification:
// Fast validation (< 50ms) - perfect for forms
$request->validate([
'email' => ['required', new VerifiedEmail(allowExternal: false)],
]);
// Or using string syntax
$request->validate([
'email' => 'required|verified_email:70,no_external',
]);
3. Use External Verification Selectively
Only enable external verification when email quality is critical:
class NewsletterSubscriptionRequest extends FormRequest
{
public function rules(): array
{
return [
// Fast validation for most users
'email' => ['required', new VerifiedEmail(allowExternal: false)],
];
}
}
// Then verify in background if needed
dispatch(new VerifySubscriberEmailJob($subscriber));
4. Cache Verification Results
For repeated verification of the same email:
use Illuminate\Support\Facades\Cache;
public function verifyEmail(string $email): EmailVerificationResult
{
return Cache::remember(
"email_verification:{$email}",
now()->addHours(24),
fn () => EmailVerifier::verify($email)
);
}
5. Batch Verification
For bulk operations, process in chunks:
use Illuminate\Support\Collection;
Collection::chunk($emails, 100)->each(function ($chunk) {
dispatch(new BulkVerifyEmailsJob($chunk));
});
Performance Impact Summary
| Approach | Latency | External Check | Best For |
|---|---|---|---|
| Sync with external | 150-600ms | ✅ Yes | Background jobs, API endpoints with async processing |
| Sync without external | 10-50ms | ❌ No | Form validation, immediate feedback |
| Queue-based | < 1ms (response) | ✅ Yes (async) | User registration, newsletter signups |
| Cached results | < 1ms | ➖ First call only | Repeated checks, bulk operations |
Recommendation: For user-facing forms, use allowExternal: false during validation and run full verification with external providers in a background queue. This provides instant feedback while still maintaining high email quality standards.
Security
- No Data Leakage: Validation messages are generic by default
- Fail-Open: External provider failures don't block legitimate users
- Rate Limiting: Recommended for public endpoints
- Input Validation: All inputs are validated and sanitized
License
MIT License. See LICENSE.md for details.
Credits
Created by Will Vincent
Support
- Issues: GitHub Issues
- Security: Please report security vulnerabilities privately
Contributing
Contributions are welcome! Please ensure:
- All tests pass (
composer test) - Type coverage remains 100% (
composer type-coverage) - PHPStan level 9 passes (
composer phpstan) - Code style follows Pint (
composer pint-test)