Skip to content

feat: Add advanced email validation methods to Email rule #56580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

MahdiBagheri71
Copy link

Description

This PR enhances the Laravel Email validation rule by adding advanced email validation methods while maintaining full backward compatibility. These methods address common real-world email validation challenges that developers face when building secure web applications.

Problem Statement

Current Laravel email validation provides basic functionality, but many applications require advanced email validation to:

  • Prevent fake/temporary email registrations
  • Block email aliases that bypass business rules
  • Ensure email deliverability through MX validation
  • Protect against sophisticated email-based attacks
  • Enforce company email policies

Solution

Added 9 new optional methods to the Illuminate\Validation\Rules\Email class:

Core Validation Methods

  • validateMxRecord() - Validates DNS/MX records to ensure email deliverability
  • preventSpoofing() - Prevents unicode spoofing attacks in email addresses
  • rfcCompliant(bool $strict = false) - Enhanced RFC compliance validation
  • withNativeValidation(bool $allowUnicode = false) - PHP native validation integration

Advanced Protection Methods

  • noDots() - Blocks Gmail dot aliases ([email protected][email protected])
  • noAliases() - Blocks common aliasing techniques ([email protected])
  • trustedDomains(?array $domains = null) - Restricts to whitelisted domains only
  • noDisposable() - Blocks temporary email services (tempmail.org, guerrillamail.com, etc.)
  • noForwarding() - Blocks email forwarding services (simplelogin.io, anonaddy.com, etc.)
  • noSuspiciousPatterns() - Blocks suspicious patterns (test123, temp456, etc.)

Convenience Methods

  • strictAdvanced() - Enables all advanced protections with one method
  • strict() - Shorthand for strict RFC compliance

Benefits to End Users

  1. Enhanced Security: Prevents fake registrations and email-based attacks
  2. Better Data Quality: Ensures valid, deliverable email addresses
  3. Flexible Configuration: Choose exactly which validations your app needs
  4. Zero Breaking Changes: Completely opt-in, existing code continues working
  5. Fluent API: Chain methods for readable validation rules
  6. Business Logic Enforcement: Block aliases and temporary emails as needed

Real-World Use Cases

// User registration with deliverability check
Email::default()->validateMxRecord()->noDisposable()

// Corporate application requiring company emails
Email::default()->trustedDomains(['company.com', 'partner.org'])

// High-security application
Email::default()->strictAdvanced()->validateMxRecord()

// Newsletter preventing aliases
Email::default()->noAliases()->noDisposable()

Why It Doesn't Break Existing Features

  • 100% Backward Compatible: All existing Email rule usage continues unchanged
  • Opt-in Only: New methods must be explicitly called
  • Existing Tests Pass: All current validation behavior preserved
  • Same Interface: Implements same contracts (Rule, DataAwareRule, ValidatorAwareRule)
  • Message System: Uses existing Laravel validation message infrastructure

How It Makes Building Web Applications Easier

  1. Reduces Custom Code: No need to write custom validation classes for common needs
  2. Framework Consistency: Uses Laravel's familiar fluent validation syntax
  3. Comprehensive Solution: Covers most email validation scenarios out-of-the-box
  4. Easy Integration: Drop-in replacement with additional capabilities
  5. Maintainable: Framework-level maintenance vs. scattered custom solutions

Implementation Details

  • Performance Optimized: Domain checks use fast array lookups
  • Extensible: Easy to add new disposable/forwarding domains
  • Well-Documented: Comprehensive PHPDoc comments
  • Error Handling: Descriptive validation messages for each failure type
  • Memory Efficient: Lazy loading of validation rules

Testing

  • Comprehensive Coverage: Tests for all new methods and edge cases
  • Existing Tests: All current tests continue to pass
  • Integration Tests: Works with Laravel's validation system
  • Error Message Tests: Validates proper error messaging

Examples

// Basic upgrade path
// Before
'email' => 'required|email'

// After
'email' => ['required', Email::default()]

// Advanced usage
'email' => [
    'required',
    Email::default()
        ->validateMxRecord()
        ->noDisposable()
        ->preventSpoofing()
        ->rules('unique:users,email')
]

Supported Use Cases

  • SaaS Applications: Prevent fake signups and improve data quality
  • Enterprise Apps: Enforce domain restrictions and security policies
  • E-commerce: Ensure customer emails are deliverable for order notifications
  • Marketing Platforms: Block temporary emails that hurt campaign metrics
  • Community Platforms: Prevent ban evasion through email aliases

This enhancement makes Laravel's email validation more powerful while maintaining the framework's principle of elegant, expressive code.

- Add validateMxRecord() for DNS/MX record validation
- Add preventSpoofing() for unicode spoofing protection
- Add noDots() to block Gmail dot aliases
- Add noAliases() to block email aliasing techniques
- Add trustedDomains() to whitelist specific domains
- Add noDisposable() to block temporary email services
- Add noForwarding() to block email forwarding services
- Add noSuspiciousPatterns() to block suspicious email patterns
- Add strictAdvanced() for comprehensive protection
- Maintain backward compatibility with existing Email rule
- Add comprehensive test coverage for new methods
Comment on lines +219 to +222
$this->trustedDomainsOnly = true;
if ($domains !== null) {
$this->customTrustedDomains = $domains;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could shorten this if wanted

Suggested change
$this->trustedDomainsOnly = true;
if ($domains !== null) {
$this->customTrustedDomains = $domains;
}
$this->trustedDomainsOnly = $domains !== null
? $domains
: true;

or even

Suggested change
$this->trustedDomainsOnly = true;
if ($domains !== null) {
$this->customTrustedDomains = $domains;
}
$this->trustedDomainsOnly = $domains === null || $domains;

Comment on lines +318 to +320
$parts = explode(':', $rule);
if (isset($parts[1])) {
$flags = explode(',', $parts[1]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could go without array indexes

Suggested change
$parts = explode(':', $rule);
if (isset($parts[1])) {
$flags = explode(',', $parts[1]);
[, $flagList] = explode(':', $rule);
if (isset($flagList)) {
$flags = explode(',', $flagList);

or simply

Suggested change
$parts = explode(':', $rule);
if (isset($parts[1])) {
$flags = explode(',', $parts[1]);
$flagList = strstr($rule, ':', true);
if (isset(flagList)) {
$flags = explode(',', flagList);

Comment on lines +317 to +325
if (str_starts_with($rule, 'email:')) {
$parts = explode(':', $rule);
if (isset($parts[1])) {
$flags = explode(',', $parts[1]);
$flags = array_filter($flags, fn($f) => $f !== 'dns');
return $flags ? 'email:' . implode(',', $flags) : 'email';
}
}
return $rule;
Copy link
Contributor

@shaedrich shaedrich Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might use early returns here:

Suggested change
if (str_starts_with($rule, 'email:')) {
$parts = explode(':', $rule);
if (isset($parts[1])) {
$flags = explode(',', $parts[1]);
$flags = array_filter($flags, fn($f) => $f !== 'dns');
return $flags ? 'email:' . implode(',', $flags) : 'email';
}
}
return $rule;
if (! str_starts_with($rule, 'email:')) {
return $rule;
}
$parts = explode(':', $rule);
if (isset($parts[1])) {
return $rule;
}
$flags = explode(',', $parts[1]);
$flags = array_filter($flags, fn($f) => $f !== 'dns');
return $flags ? 'email:' . implode(',', $flags) : 'email';

$rules = array_map(function ($rule) {
if (str_starts_with($rule, 'email:')) {
$parts = explode(':', $rule);
if (isset($parts[1])) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this ever not be true?

Comment on lines +361 to +384
if ($this->blockGmailDots && $this->hasGmailDots($localPart, $domain)) {
$this->messages[] = 'Gmail addresses with dots are not allowed.';
return false;
}

if ($this->blockAliases && $this->hasAliases($localPart)) {
$this->messages[] = 'Email aliases are not allowed.';
return false;
}

if ($this->blockDisposable && $this->isDisposableEmail($domain)) {
$this->messages[] = 'Temporary or disposable email services are not allowed.';
return false;
}

if ($this->blockForwarding && $this->isForwardingService($domain)) {
$this->messages[] = 'Email forwarding services are not allowed.';
return false;
}

if ($this->blockSuspiciousPatterns && $this->hasSuspiciousPatterns($localPart)) {
$this->messages[] = 'This email format appears to be temporary or invalid.';
return false;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't you use the messages from the validation translation file here?

Comment on lines +483 to +505
return [
'gmail.com',
'googlemail.com',
'outlook.com',
'hotmail.com',
'live.com',
'msn.com',
'icloud.com',
'me.com',
'mac.com',
'yahoo.com',
'yahoo.co.uk',
'yahoo.ca',
'yahoo.com.au',
'yahoo.de',
'yahoo.fr',
'yahoo.es',
'yahoo.it',
'ymail.com',
'rocketmail.com',
'aol.com',
'aim.com',
];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What makes them especially trusted? Is this a custom list or does it come from somewhere official?

Comment on lines +580 to +592
/**
* Check for email aliases.
*
* @param string $localPart
* @return bool
*/
protected function hasAliases(string $localPart): bool
{
return str_contains($localPart, '+') ||
preg_match('/\.{2,}/', $localPart) ||
str_starts_with($localPart, '.') ||
str_ends_with($localPart, '.');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we want the gmail dots to be provider-specific while we don't apply the aliases solely to providers that actually implement that part of the spec

'/^test\d*$/', // Test emails
'/^temp\d*$/', // Temporary emails
'/^noreply/', // No-reply emails
'/^admin\d*$/', // Admin emails
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is [email protected] suspicious?

protected function hasSuspiciousPatterns(string $localPart): bool
{
$suspiciousPatterns = [
'/^[a-z]\d{8,}$/', // Random letters + numbers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why specifically 8?

Comment on lines +386 to +427
public function testNoAliases()
{
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);

$this->fails(
Rule::email()->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);

$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);

$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);

$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);

$this->passes(
(new Email())->noAliases(),
'[email protected]'
);

$this->passes(
Rule::email()->noAliases(),
'[email protected]'
);
}
Copy link
Contributor

@shaedrich shaedrich Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use a data provider here

Suggested change
public function testNoAliases()
{
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
Rule::email()->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->passes(
(new Email())->noAliases(),
'[email protected]'
);
$this->passes(
Rule::email()->noAliases(),
'[email protected]'
);
}
#[DataProvider('provideDotAliasRules')]
public function testNoAliases(Email $rule, string $emailAddress, ?array $errorMessages = null)
{
if ($errorMessages === null) {
$this->passes($rule, $emailAddress);
} else {
$this->fails($rule, $emailAddress, $errorMessages);
}
}
public static function provideDotAliasRules()
{
return [
'email class and gmail address with plus' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.'],
},
'rule method and gmail address with plus' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with dots within' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with leading dots' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with trailing dots' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address without pluses and dots' => [
(new Email())->noAliases(),
'[email protected]',
],
'rule method and gmail address without pluses and dots' => [
Rule::email()->noAliases(),
'[email protected]'
],
];
}

or

Suggested change
public function testNoAliases()
{
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
Rule::email()->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->fails(
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
);
$this->passes(
(new Email())->noAliases(),
'[email protected]'
);
$this->passes(
Rule::email()->noAliases(),
'[email protected]'
);
}
#[DataProvider('providePassingDotAliasRules')]
public function testNoAliases(Email $rule, string $emailAddress, array $errorMessages = null)
{
$this->passes($rule, $emailAddress);
}
public static function providePassingDotAliasRules()
{
return [
'email class and gmail address without pluses and dots' => [
(new Email())->noAliases(),
'[email protected]',
],
'rule method and gmail address without pluses and dots' => [
Rule::email()->noAliases(),
'[email protected]'
],
];
}
#[DataProvider('provideFailingDotAliasRules')]
public function testNoAliases(Email $rule, string $emailAddress, array $errorMessages = null)
{
$this->fails($rule, $emailAddress, $errorMessages);
}
public static function provideFailingDotAliasRules()
{
return [
'email class and gmail address with plus' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.'],
},
'rule method and gmail address with plus' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with dots within' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with leading dots' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
'email class and gmail address with trailing dots' => [
(new Email())->noAliases(),
'[email protected]',
['Email aliases are not allowed.']
],
];
}

@taylorotwell
Copy link
Member

Any further email validation should just be a package imo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants