willvincent / laravel-email-verifier
Email sanity checks + MX + disposable detection + optional external verification for Laravel 11/12.
Fund package maintenance!
willvincent
Thanks Dev
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/willvincent/laravel-email-verifier
Requires
- php: ^8.2|^8.3
- illuminate/contracts: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- driftingly/rector-laravel: ^2.1
- larastan/larastan: ^3.8
- laravel/pint: ^1.26
- mockery/mockery: ^1.6
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- pestphp/pest-plugin-type-coverage: ^3.6
- rector/rector: ^2.2
This package is auto-updated.
Last update: 2025-12-31 22:11:51 UTC
README
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
- 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)