├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── gmail-unique.php ├── phpunit.xml ├── src ├── Console │ └── InstallCommand.php ├── Facades │ └── GmailUnique.php ├── GmailUniqueServiceProvider.php ├── Services │ └── GmailUniqueService.php └── Traits │ └── HasNormalizedEmail.php └── tests ├── Feature └── UserModelValidationTest.php ├── Pest.php ├── TestCase.php ├── TestUser.php └── Unit └── GmailNormalizerTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/hot 3 | /public/storage 4 | /storage/*.key 5 | /vendor 6 | .env 7 | .env.backup 8 | .env.production 9 | .phpunit.result.cache 10 | Homestead.json 11 | Homestead.yaml 12 | auth.json 13 | npm-debug.log 14 | yarn-error.log 15 | /.fleet 16 | /.idea 17 | /.vscode 18 | 19 | # Laravel Specific 20 | /bootstrap/cache/* 21 | !/bootstrap/cache/.gitignore 22 | /storage/app/* 23 | !/storage/app/.gitignore 24 | !/storage/app/public/ 25 | /storage/app/public/* 26 | !/storage/app/public/.gitignore 27 | /storage/framework/cache/* 28 | !/storage/framework/cache/.gitignore 29 | /storage/framework/sessions/* 30 | !/storage/framework/sessions/.gitignore 31 | /storage/framework/testing/* 32 | !/storage/framework/testing/.gitignore 33 | /storage/framework/views/* 34 | !/storage/framework/views/.gitignore 35 | /storage/logs/* 36 | !/storage/logs/.gitignore 37 | 38 | 39 | /composer.lock 40 | /package-lock.json 41 | /yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alizio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Gmail Unique 2 | 3 | [![Packagist License](https://img.shields.io/badge/Licence-MIT-blue)](https://github.com/aliziodev/laravel-gmail-unique/blob/main/LICENSE) 4 | [![Latest Stable Version](https://img.shields.io/packagist/v/aliziodev/laravel-gmail-unique?label=Stable)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 5 | [![Total Downloads](https://img.shields.io/packagist/dt/aliziodev/laravel-gmail-unique.svg?label=Downloads)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 6 | [![PHP Version](https://img.shields.io/packagist/php-v/aliziodev/laravel-gmail-unique.svg)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 7 | [![Laravel Version](https://img.shields.io/badge/Laravel-10.x-red)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 8 | [![Laravel Version](https://img.shields.io/badge/Laravel-11.x-red)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 9 | [![Laravel Version](https://img.shields.io/badge/Laravel-12.x-red)](https://packagist.org/packages/aliziodev/laravel-gmail-unique) 10 | 11 | Laravel Gmail Unique is a package that normalizes Gmail addresses during validation to prevent duplicate user registrations with Gmail's dot variations and plus aliases. Gmail treats addresses like john.doe@gmail.com, johndoe@gmail.com, and john+alias@gmail.com as the same account, but standard validation treats them as different emails. 12 | 13 | According to Google's official documentation [Google: Getting messages sent to a dotted version of my address](https://support.google.com/mail/answer/10313#zippy=%2Cgetting-messages-sent-to-a-dotted-version-of-my-address) , if you add dots to a Gmail address, you'll still get that email. For example, if your email is johnsmith@gmail.com, you own all dotted versions of your address: 14 | 15 | - john.smith@gmail.com 16 | - jo.hn.sm.ith@gmail.com 17 | - j.o.h.n.s.m.i.t.h@gmail.com 18 | 19 | This package ensures that your application recognizes these variations as the same email address, preventing duplicate accounts and improving user experience. This is especially recommended for SaaS systems with trial subscriptions to prevent users from creating multiple trial accounts using Gmail variations. 20 | 21 | ## Features 22 | 23 | - Normalizes Gmail addresses by removing dots and plus aliases 24 | - Prevents duplicate user registrations with Gmail variations 25 | - Easy integration with Laravel's validation system 26 | - Configurable for custom domains and email column names 27 | - Works with Laravel's Eloquent models via a simple trait 28 | 29 | ## Installation 30 | 31 | You can install the package via composer: 32 | 33 | ```bash 34 | composer require aliziodev/laravel-gmail-unique 35 | ``` 36 | 37 | After installation, you can use the package's install command: 38 | 39 | ```bash 40 | php artisan gmail-unique:install 41 | ``` 42 | 43 | ## Configuration 44 | 45 | After publishing the configuration, you can modify the settings in `config/gmail-unique.php` : 46 | 47 | ```php 48 | return [ 49 | // Email domains to normalize (default: gmail.com and googlemail.com) 50 | 'domains' => ['gmail.com', 'googlemail.com'], 51 | 52 | // The column name used for email in your database (default: email) 53 | 'email_column' => 'email', 54 | 55 | // Error message for duplicate Gmail addresses 56 | 'error_message' => 'This email is already taken (normalized Gmail detected).' 57 | ]; 58 | ``` 59 | 60 | ## Usage 61 | 62 | ### Using the Trait 63 | 64 | The simplest way to use this package is by adding the `HasNormalizedEmail` trait to your User model: 65 | 66 | ```php 67 | $email, 112 | 'normalized' => $normalized, 113 | 'isDuplicate' => $isDuplicate 114 | ]; 115 | } 116 | } 117 | ``` 118 | 119 | ### Using the Service 120 | 121 | If you need more control, you can use the `GmailUniqueService` directly: 122 | 123 | ```php 124 | gmailService = $gmailService; 138 | } 139 | 140 | public function checkEmail($email) 141 | { 142 | // Normalize an email address 143 | $normalized = $this->gmailService->normalize($email); 144 | 145 | // Check if a normalized version already exists 146 | $isDuplicate = $this->gmailService->isDuplicate($email, User::class, $excludeId = null); 147 | 148 | return [ 149 | 'original' => $email, 150 | 'normalized' => $normalized, 151 | 'isDuplicate' => $isDuplicate 152 | ]; 153 | } 154 | } 155 | ``` 156 | 157 | ### Custom Validation Rules 158 | 159 | You can also use the service in custom validation rules: 160 | 161 | ```php 162 | use Aliziodev\GmailUnique\Services\GmailUniqueService; 163 | use App\Models\User; 164 | 165 | // In a form request or controller 166 | $validated = $request->validate([ 167 | 'name' => ['required', 'string', 'max:255'], 168 | 'email' => [ 169 | 'required', 170 | 'string', 171 | 'email', 172 | 'max:255', 173 | function ($attribute, $value, $fail) { 174 | $gmailService = app(GmailUniqueService::class); 175 | if ($gmailService->isDuplicate($value, User::class)) { 176 | $fail('This email address is already taken.'); 177 | } 178 | } 179 | ], 180 | 'password' => ['required', 'string', 'min:8', 'confirmed'], 181 | ]); 182 | ``` 183 | 184 | ## Testing 185 | 186 | The package includes comprehensive tests. You can run them with: 187 | 188 | ```bash 189 | ./vendor/bin/pest 190 | ``` 191 | 192 | ## License 193 | 194 | The MIT License (MIT). Please see License File for more information. 195 | 196 | ## Contributing 197 | 198 | Contributions are welcome! Please create issues or pull requests on the GitHub repository. 199 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aliziodev/laravel-gmail-unique", 3 | "description": "A Laravel package that normalizes Gmail addresses during validation to prevent duplicate user registrations with Gmail's dot variations and plus aliases, ideal for SaaS applications with trial subscriptions.", 4 | "type": "library", 5 | "keywords": [ 6 | "laravel", 7 | "gmail", 8 | "email-validation", 9 | "email-normalization", 10 | "gmail-alias", 11 | "unique-emails", 12 | "saas", 13 | "trial-prevention" 14 | ], 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Alizio", 19 | "email": "aliziodev@gmail.com", 20 | "homepage": "https://github.com/aliziodev", 21 | "role": "Developer" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.1|^8.2", 26 | "illuminate/support": "^10.0|^11.0|^12.0", 27 | "illuminate/console": "^10.0|^11.0|^12.0" 28 | }, 29 | "require-dev": { 30 | "orchestra/testbench": "^8.0|^9.0", 31 | "pestphp/pest": "^2.34", 32 | "phpstan/phpstan": "^1.10", 33 | "laravel/pint": "^1.13" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Aliziodev\\GmailUnique\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "test": "vendor/bin/pest", 47 | "test-coverage": "vendor/bin/pest --coverage", 48 | "format": "vendor/bin/pint", 49 | "analyse": "vendor/bin/phpstan analyse", 50 | "check": [ 51 | "@format", 52 | "@analyse", 53 | "@test" 54 | ] 55 | }, 56 | "extra": { 57 | "laravel": { 58 | "providers": [ 59 | "Aliziodev\\GmailUnique\\GmailUniqueServiceProvider" 60 | ], 61 | "aliases": { 62 | "GmailUnique": "Aliziodev\\GmailUnique\\Facades\\GmailUnique" 63 | } 64 | } 65 | }, 66 | "prefer-stable": true, 67 | "config": { 68 | "sort-packages": true, 69 | "allow-plugins": { 70 | "pestphp/pest-plugin": true 71 | } 72 | }, 73 | "support": { 74 | "issues": "https://github.com/aliziodev/laravel-username-guards/issues", 75 | "source": "https://github.com/aliziodev/laravel-username-guards" 76 | }, 77 | "suggest": { 78 | "aliziodev/laravel-username-guards": "A comprehensive username filtering package for Laravel that helps filter profanity, adult content, illegal activities, gambling, spam, and religion abuse words" 79 | } 80 | } -------------------------------------------------------------------------------- /config/gmail-unique.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'gmail.com', 15 | 'googlemail.com' 16 | ], 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Default Email Column 21 | |-------------------------------------------------------------------------- 22 | | 23 | | The default email column name that will be used for validation. 24 | | The package will look for this column in the model to perform normalization. 25 | | 26 | */ 27 | 'email_column' => 'email', 28 | 29 | /* 30 | |-------------------------------------------------------------------------- 31 | | Error Message 32 | |-------------------------------------------------------------------------- 33 | | 34 | | The error message that will be displayed when a normalized email 35 | | already exists in the database. 36 | | 37 | */ 38 | 'error_message' => 'This email is already taken (normalized Gmail detected).' 39 | ]; 40 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./app 15 | ./src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | info('Installing Laravel Gmail Unique...'); 34 | 35 | // Publish configuration 36 | $this->publishConfiguration(); 37 | 38 | // Show usage information 39 | $this->showUsageInformation(); 40 | 41 | $this->info('Installation completed!'); 42 | } 43 | 44 | /** 45 | * Publish the configuration file. 46 | */ 47 | protected function publishConfiguration(): void 48 | { 49 | $this->info('Publishing configuration...'); 50 | 51 | $this->callSilently('vendor:publish', [ 52 | '--tag' => 'gmail-unique', 53 | '--force' => true, 54 | ]); 55 | 56 | $this->line(' Configuration file published successfully.'); 57 | } 58 | 59 | /** 60 | * Show usage information. 61 | */ 62 | protected function showUsageInformation(): void 63 | { 64 | $this->info('How to use Laravel Gmail Unique:'); 65 | $this->line('1. Add the HasNormalizedEmail trait to your User model:'); 66 | $this->line(' use Aliziodev\\GmailUnique\\Traits\\HasNormalizedEmail;'); 67 | $this->line('2. That\'s it! Your Gmail emails will now be normalized during validation.'); 68 | $this->line(' This prevents duplicate accounts with email variations like:'); 69 | $this->line(' - user@gmail.com'); 70 | $this->line(' - u.s.e.r@gmail.com'); 71 | $this->line(' - user+alias@gmail.com'); 72 | } 73 | } -------------------------------------------------------------------------------- /src/Facades/GmailUnique.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected $commands = [ 20 | Console\InstallCommand::class, 21 | ]; 22 | 23 | /** 24 | * Bootstrap the application services. 25 | * 26 | * @return void 27 | */ 28 | public function boot() 29 | { 30 | if ($this->app->runningInConsole()) { 31 | $this->publishes([ 32 | __DIR__ . '/../config/gmail-unique.php' => config_path('gmail-unique.php'), 33 | ], 'gmail-unique'); 34 | } 35 | } 36 | 37 | /** 38 | * Register the application services. 39 | * 40 | * @return void 41 | */ 42 | public function register() 43 | { 44 | $this->mergeConfigFrom( 45 | __DIR__ . '/../config/gmail-unique.php', 46 | 'gmail-unique' 47 | ); 48 | 49 | $this->app->singleton(GmailUniqueService::class, function ($app) { 50 | $config = $app['config']->get('gmail-unique'); 51 | return new GmailUniqueService( 52 | $config['domains'] ?? null, 53 | $config['email_column'] ?? null 54 | ); 55 | }); 56 | 57 | // Register the commands 58 | $this->commands($this->commands); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Services/GmailUniqueService.php: -------------------------------------------------------------------------------- 1 | domains = $domains ?? config('gmail-unique.domains', ['gmail.com', 'googlemail.com']); 55 | $this->emailColumn = $emailColumn ?? config('gmail-unique.email_column', 'email'); 56 | foreach ($this->domains as $domain) { 57 | $this->domainLookup[strtolower($domain)] = true; 58 | } 59 | } 60 | 61 | /** 62 | * Normalize the Gmail address by removing dots and aliases 63 | * 64 | * Gmail treats the following as identical: 65 | * - Dots in the local part (john.doe@gmail.com = johndoe@gmail.com) 66 | * - Anything after a plus sign (john.doe+work@gmail.com = john.doe@gmail.com) 67 | * 68 | * This method standardizes these variations to ensure uniqueness checks work correctly. 69 | * 70 | * @param string $email The email address to normalize 71 | * @return string The normalized email address 72 | * @throws \InvalidArgumentException If the email format is invalid 73 | */ 74 | public function normalize(string $email): string 75 | { 76 | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { 77 | return $email; // Return as is if not a valid email 78 | } 79 | 80 | $atPos = strrpos($email, '@'); 81 | if ($atPos === false) { 82 | return $email; // Return as is if no @ symbol 83 | } 84 | 85 | $local = substr($email, 0, $atPos); 86 | $domain = strtolower(substr($email, $atPos + 1)); 87 | $local = strtolower($local); 88 | 89 | if (isset($this->domainLookup[$domain])) { 90 | $local = preg_replace('/\+.*/', '', $local); 91 | $local = str_replace('.', '', $local); 92 | } 93 | 94 | return "$local@$domain"; 95 | } 96 | 97 | /** 98 | * Check if a normalized version of the email already exists in the database 99 | * 100 | * Normalizes the provided email and checks if it exists in the specified model table. 101 | * Optionally excludes a specific record by ID (useful for update operations). 102 | * 103 | * @param string $email The email address to check for duplicates 104 | * @param string $modelClass The fully qualified class name of the model to check against (e.g., 'App\Models\User') 105 | * @param int|null $excludeId Optional ID to exclude from the duplicate check (for update scenarios) 106 | * @return bool True if a duplicate exists, false otherwise 107 | */ 108 | public function isDuplicate(string $email, string $modelClass, $excludeId = null): bool 109 | { 110 | $normalized = $this->normalize($email); 111 | 112 | $query = $modelClass::whereRaw("LOWER({$this->emailColumn}) = ?", [$normalized]); 113 | 114 | if ($excludeId) { 115 | $query->where('id', '!=', $excludeId); 116 | } 117 | 118 | return $query->exists(); 119 | } 120 | 121 | /** 122 | * Get the email column name used for database queries 123 | * 124 | * @return string The email column name 125 | */ 126 | public function getEmailColumn(): string 127 | { 128 | return $this->emailColumn; 129 | } 130 | 131 | /** 132 | * Get the error message for duplicate emails 133 | * 134 | * @return string The error message from configuration 135 | */ 136 | public function getErrorMessage(): string 137 | { 138 | return config('gmail-unique.error_message', 'This email is already taken (normalized Gmail detected).'); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Traits/HasNormalizedEmail.php: -------------------------------------------------------------------------------- 1 | getEmailColumn(); 58 | 59 | if (!isset($model->$emailColumn)) return true; 60 | 61 | $exists = $normalizer->isDuplicate($model->$emailColumn, $model::class, $model->id ?? null); 62 | 63 | if ($exists) { 64 | throw ValidationException::withMessages([ 65 | $emailColumn => $normalizer->getErrorMessage() 66 | ]); 67 | } 68 | 69 | return true; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Feature/UserModelValidationTest.php: -------------------------------------------------------------------------------- 1 | 'Test User 1', 10 | 'email' => 'aliziodev@gmail.com', 11 | 'password' => bcrypt('password123') 12 | ]); 13 | 14 | $this->expectException(ValidationException::class); 15 | TestUser::create([ 16 | 'name' => 'Test User 2', 17 | 'email' => 'a.l.i.z.i.o.d.e.v@gmail.com', 18 | 'password' => bcrypt('password123') 19 | ]); 20 | }); 21 | 22 | it('allows saving non-gmail duplicate variations', function () { 23 | 24 | TestUser::create([ 25 | 'name' => 'Test User 3', 26 | 'email' => 'user@example.com', 27 | 'password' => bcrypt('password123') 28 | ]); 29 | 30 | $user = TestUser::create([ 31 | 'name' => 'Test User 4', 32 | 'email' => 'user@different-domain.com', 33 | 'password' => bcrypt('password123') 34 | ]); 35 | 36 | expect($user)->toBeInstanceOf(TestUser::class); 37 | expect($user->email)->toBe('user@different-domain.com'); 38 | }); 39 | 40 | it('allows updating own email with same normalized version', function () { 41 | $user = TestUser::create([ 42 | 'name' => 'Test User 5', 43 | 'email' => 'update.test@gmail.com', 44 | 'password' => bcrypt('password123') 45 | ]); 46 | 47 | $user->email = 'up.date.te.st@gmail.com'; 48 | $user->save(); 49 | 50 | expect($user->email)->toBe('up.date.te.st@gmail.com'); 51 | }); -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature', 'Unit'); 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Expectations 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When you're writing tests, you often need to check that values meet certain conditions. The 22 | | "expect()" function gives you access to a set of "expectations" methods that you can use 23 | | to assert different things. Of course, you may extend the Expectation API at any time. 24 | | 25 | */ 26 | 27 | expect()->extend('toBeOne', function () { 28 | return $this->toBe(1); 29 | }); 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Functions 34 | |-------------------------------------------------------------------------- 35 | | 36 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 37 | | project that you don't want to repeat in every file. Here you can also expose helpers as 38 | | global functions to help you to reduce the number of lines of code in your test files. 39 | | 40 | */ 41 | 42 | function something() 43 | { 44 | // .. 45 | } 46 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | loadLaravelMigrations(); 24 | $this->app['config']->set('gmail-unique.domains', ['gmail.com', 'googlemail.com']); 25 | $this->app['config']->set('gmail-unique.email_column', 'email'); 26 | } 27 | 28 | protected function getEnvironmentSetUp($app) 29 | { 30 | // Setup default database for testing 31 | $app['config']->set('database.default', 'testing'); 32 | $app['config']->set('database.connections.testing', [ 33 | 'driver' => 'sqlite', 34 | 'database' => ':memory:', 35 | 'prefix' => '', 36 | ]); 37 | } 38 | } -------------------------------------------------------------------------------- /tests/TestUser.php: -------------------------------------------------------------------------------- 1 | normalize('j.o.k.o+test@gmail.com'))->toBe('joko@gmail.com'); 10 | expect($service->normalize('Test.Email+spam@googlemail.com'))->toBe('testemail@googlemail.com'); 11 | expect($service->normalize('non-gmail@example.com'))->toBe('non-gmail@example.com'); 12 | }); 13 | 14 | it('detects duplicate email using service class', function () { 15 | TestUser::create([ 16 | 'name' => 'Test User 6', 17 | 'email' => 'aliziodev@gmail.com', 18 | 'password' => bcrypt('password123') 19 | ]); 20 | 21 | $service = new GmailUniqueService(); 22 | expect($service->isDuplicate('a.l.i.z.i.o.d.e.v@gmail.com', TestUser::class))->toBeTrue(); 23 | }); --------------------------------------------------------------------------------