mailboxvalidator/mailboxvalidator-php
MailboxValidator for Laravel validates email addresses through the MailboxValidator API, helping identify invalid, disposable, role-based, catch-all, and risky emails to improve deliverability and reduce bounces.
This is a plain PHP SDK, not a Laravel package — there is no service provider, config file, or facade, and no auto-discovery. Installation and wiring are on you.
composer require mailboxvalidator/mailboxvalidator-php
Where to look first:
new MailboxValidator\MailboxValidator($apiKey).ValidateEmail($email), IsDisposableEmail($email), and IsFreeEmail($email). ValidateEmail returns the full result set (syntax, MX, disposable, free, role, catch-all, credits remaining); the other two are narrower calls.email_address, status, is_free, is_disposable, is_role, is_catchall, credits, error_code, and error_message are what you'll build on, but confirm the exact casing and types you receive rather than trusting docs.Wire it into the container in AppServiceProvider::register() or a small dedicated provider:
// config/services.php
'mailboxvalidator' => [
'key' => env('MAILBOXVALIDATOR_API_KEY'),
],
// AppServiceProvider
$this->app->singleton(MailboxValidator::class, fn () => new \MailboxValidator\MailboxValidator(
config('services.mailboxvalidator.key')
));
First use case: gate user registration. Block disposable addresses at signup by adding a rule to your RegisterRequest, keeping Laravel's built-in email rule in front of it so you never spend an API credit on obvious garbage.
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'unique:users,email', new NotDisposable()],
];
}
Don't stop at the raw library call — the single highest-value thing you can do on day one is define a one-method interface (EmailVerifier::verify(string $email): EmailVerdict) that returns a small DTO. Every pattern below depends on that seam.
Wrap, don't call the SDK directly. The library returns an untyped decoded response (object/array, string-typed booleans, no exceptions). Map it into a readonly DTO once, in one place:
final class EmailVerdict
{
public function __construct(
public string $email,
public bool $deliverable,
public bool $disposable,
public bool $free,
public bool $role,
public bool $catchAll,
public ?string $errorCode = null,
) {}
}
Once this exists, the rest of the app never touches the SDK, and you can swap the implementation for direct HTTP calls or a different provider without a refactor.
Validation rule for sync paths. A Rule object is the cleanest integration for registration/checkout forms. Decide the policy explicitly: block disposable + role, allow free (or require a work email for B2B), and treat catch-all as "risky" rather than "invalid" — many legitimate corporate domains are catch-all.
public function passes($attribute, $value): bool
{
$verdict = app(EmailVerifier::class)->verify($value);
if ($verdict->errorCode) {
return true; // fail open on provider errors, or false to fail closed
}
return $verdict->deliverable && ! $verdict->disposable && ! $verdict->role;
}
Never call the API on a bulk import path. For CSV imports, list uploads, or re-verification campaigns, dispatch a job per address and persist the result:
class VerifyEmailJob implements ShouldQueue
{
public int $tries = 3;
public int $timeout = 30;
public function __construct(public int $emailRecordId) {}
public function handle(EmailVerifier $verifier): void
{
$record = EmailRecord::findOrFail($this->emailRecordId);
$verdict = $verifier->verify($record->email);
$record->update([...$verdict->toArray(), 'checked_at' => now()]);
}
}
Add RateLimited middleware on the job (->middleware([new RateLimited('mailboxvalidator')])) with a named limiter in AppServiceProvider — this is how you stay under the plan limit and avoid hitting a burst of error_code responses.
Cache aggressively, keyed by normalized email. Validation results are stable for weeks; a re-check of a 30-day-old valid address is a wasted credit. Cache sha256(lower(trim($email))) for 30–90 days for deliverable results, and only 1–7 days for catch-all/unknown so a domain that fixes its DNS gets re-evaluated. A second, domain-level cache for MX/DNS results is worth it if you import from the same corporate domains repeatedly.
Store the verdict, not just the boolean. Persist the individual flags (is_disposable, is_role, is_catchall, checked_at, credits_remaining). You will want to retroactively loosen or tighten the policy without re-spending credits. The credits field in every response is a free monitoring signal — log it and alert when it trends toward zero.
Test against your interface, not the network. Bind a fake in TestCase::setUp() or use $this->mock(EmailVerifier::class) in feature tests, and keep one thin integration test (marked @group integration, skipped without a key) to catch response-shape drift.
Http::fake() will not work. The SDK issues raw cURL requests; it is not built on Guzzle or PSR-18, so Laravel's HTTP client faking can't intercept it. Mock your EmailVerifier contract instead. This is the single most common surprise when adding tests.ValidateEmail, IsDisposableEmail, and IsFreeEmail for one signup costs three credits. Always use ValidateEmail alone.status/deliverable false is an answer, not an exception. The library returns error_code and error_message inside the payload rather than throwing. Check error_code before reading any flag, otherwise a failed lookup reads as "disposable = false", silently letting bad emails through."true"/"false" or 0/1. Cast explicitly in your DTO or a filter_var(..., FILTER_VALIDATE_BOOLEAN); loose comparisons against true will bite you.$timeout set, and if you retry, be aware retries consume credits too. Retry only on transport/network errors, not on error_code responses that indicate the address itself is unusable.strtolower(trim($email)), and run idn_to_ascii() on the domain part so müller.de and its punycode form hash to the same cache key.is_catchall as "send a confirmation email and see" rather than reject. Similarly, is_server_down is transient — don't permanently blacklist..env only, and remember php artisan config:cache after changing it — a stale config cache with an empty key yields confusing error_code responses rather than a clear auth failure.if statements.composer.json and lean on your interface seam so a swap to direct REST calls (/v1/validation/single/) is an afternoon, not a rewrite.filter_var + a checked disposable-domain blocklist + a confirmation email covers most of the value at zero cost. This API earns its keep on lead-gen forms, B2B signups, and anywhere an undeliverable address has a real cost.How can I help you explore Laravel packages today?