├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── username-guard.php ├── phpunit.xml ├── resources └── words │ ├── adult │ ├── en.php │ ├── global.php │ └── id.php │ ├── gambling │ ├── en.php │ ├── global.php │ └── id.php │ ├── hate │ ├── en.php │ ├── global.php │ └── id.php │ ├── illegal │ ├── en.php │ ├── global.php │ └── id.php │ ├── profanity │ ├── en.php │ ├── global.php │ └── id.php │ ├── religion_abuse │ ├── en.php │ ├── global.php │ └── id.php │ ├── reserved │ ├── en.php │ ├── global.php │ └── id.php │ ├── scam │ ├── en.php │ ├── global.php │ └── id.php │ └── spam │ ├── en.php │ ├── global.php │ └── id.php ├── src ├── Console │ ├── ClearCommand.php │ └── InstallCommand.php ├── Exceptions │ └── UsernameGuardException.php ├── Facades │ └── Username.php ├── Rules │ └── UsernameRule.php ├── Services │ └── UsernameService.php └── UsernameGuardServiceProvider.php └── tests ├── Feature └── UsernameServiceFeatureTest.php ├── Pest.php ├── TestCase.php └── Unit ├── InvalidConfigurationTest.php ├── UsernameGuardExceptionTest.php ├── UsernameRuleTest.php └── UsernameServiceTest.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 Dev 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Username Guards 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/aliziodev/laravel-username-guards.svg?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/aliziodev/laravel-username-guards.svg?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 5 | [![PHP Version](https://img.shields.io/packagist/php-v/aliziodev/laravel-username-guards.svg?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 6 | [![Laravel Version](https://img.shields.io/badge/Laravel-10.x-red?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 7 | [![Laravel Version](https://img.shields.io/badge/Laravel-11.x-red?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 8 | [![Laravel Version](https://img.shields.io/badge/Laravel-12.x-red?style=flat-square)](https://packagist.org/packages/aliziodev/laravel-username-guards) 9 | 10 | Laravel Username Guards is a comprehensive package for validating usernames in Laravel applications. It provides robust validation features including pattern matching and prohibited word filtering across multiple languages. 11 | 12 | ## Features 13 | 14 | - Pattern-based username validation (length, allowed characters, format) 15 | - Prohibited word filtering in multiple categories (profanity, adult, spam, etc.) 16 | - Multi-language support (en, id, and extendable) 17 | - Flexible configuration 18 | - Easy integration with Laravel Validation 19 | - Facade for direct usage 20 | - Caching for better performance 21 | 22 | ## Installation 23 | 24 | Install the package via Composer: 25 | 26 | ```bash 27 | composer require aliziodev/laravel-username-guards 28 | ``` 29 | 30 | ## Console Commands 31 | 32 | ```bash 33 | php artisan username-guard:install 34 | ``` 35 | 36 | This command: 37 | 38 | - Publishes configuration file 39 | - Publishes word resources 40 | - Sets up basic configuration 41 | 42 | Cache Clearing Command: 43 | 44 | ```bash 45 | php artisan username-guard:clear 46 | ``` 47 | 48 | This command clears all caches related to the package: 49 | 50 | - Configuration cache 51 | - Application cache 52 | - Package discovery cache 53 | 54 | ## Configuration 55 | 56 | After publishing the configuration file, you can customize various options in config/username-guard.php : 57 | 58 | ### Language Settings 59 | 60 | ```php 61 | // Default language 62 | 'default_locale' => env('APP_LOCALE', 'en'), 63 | 64 | // Supported languages 65 | 'supported_locales' => [ 66 | 'global', // Global words (REQUIRED, applies to all languages) 67 | 'en', // English 68 | 'id', // Indonesian 69 | // Add other languages as needed 70 | ], 71 | ``` 72 | 73 | ### Filtering Mode 74 | 75 | ```php 76 | // Check all supported languages 77 | 'check_all_locales' => env('WORD_FILTER_CHECK_ALL_LOCALES', true), 78 | 79 | // Only use default language + global 80 | 'preferred_locale_only' => env('WORD_FILTER_PREFERRED_LOCALE_ONLY', false), 81 | ``` 82 | 83 | ### Word Categories 84 | 85 | ```php 86 | 'categories' => [ 87 | // Default categories 88 | 'profanity' => true, 89 | 'adult' => true, 90 | 'gambling' => true, 91 | 'religion_abuse' => true, 92 | 'illegal' => true, 93 | 'spam' => true, 94 | 'reserved' => true, 95 | 'hate' => true, 96 | 'scam' => true, 97 | 98 | // Optional categories 99 | 'political' => false, 100 | 'trending' => false, 101 | 102 | // Custom category example 103 | // 'trademark' => true, 104 | 105 | ], 106 | ``` 107 | 108 | ### Validation Patterns 109 | 110 | ```php 111 | 'patterns' => [ 112 | // Character sets 113 | 'sets' => [ 114 | 'alpha' => 'a-zA-Z', 115 | 'numeric' => '0-9', 116 | 'special' => '_-', 117 | 'extra' => '.', 118 | 'spaces' => '\s', 119 | ], 120 | 121 | // Common validation rules 122 | 'rules' => [ 123 | 'start_alpha' => '/^[a-zA-Z]/', // Must start with letter 124 | 'end_alphanumeric' => '/[a-z0-9]$/', // Must end with letter/number 125 | 'no_consecutive_dash' => '/[-]{2,}/', // No consecutive dashes 126 | 'no_consecutive_underscore' => '/[_]{2,}/', // No consecutive underscores 127 | // ... 128 | ], 129 | 130 | // Pattern presets 131 | 'presets' => [ 132 | 'username' => [ 133 | 'allowed_chars' => '[^a-zA-Z0-9_-]', 134 | 'rules' => ['start_alpha', 'no_consecutive_dash', 'no_consecutive_underscore', 'no_consecutive_special_mix'], 135 | 'min_length' => 3, 136 | 'max_length' => 20, 137 | ], 138 | // ... 139 | ], 140 | 141 | // Active patterns 142 | 'active' => [ 143 | 'username' => true, // Enable username pattern 144 | // ... 145 | ], 146 | 147 | ], 148 | ``` 149 | 150 | ### Cache Settings 151 | 152 | ```php 153 | 'cache' => [ 154 | 'enabled' => env('WORD_FILTER_CACHE_ENABLED', true), // Enabled by default 155 | 'ttl' => env('WORD_FILTER_CACHE_TTL', 86400), // 24 hours 156 | 'store' => env('WORD_FILTER_CACHE_STORE', null), // Cache store: file, redis, etc 157 | ], 158 | ``` 159 | 160 | ## Usage 161 | 162 | ### Using Validation Rule 163 | 164 | The easiest way to use this package is with the UsernameRule in Laravel validation: 165 | 166 | ```php 167 | ['required', new UsernameRule()], 180 | ]; 181 | } 182 | } 183 | ``` 184 | 185 | Then use the form request in your controller: 186 | 187 | ```php 188 | json([ 208 | 'valid' => true, 209 | 'username' => $request->username, 210 | 'message' => 'Username is valid and can be used' 211 | ]); 212 | } 213 | } 214 | ``` 215 | 216 | ### Using Facade 217 | 218 | You can also use the Username facade for direct validation: 219 | 220 | ```php 221 | get('username'); 240 | 241 | if (empty($username)) { 242 | return response()->json([ 243 | 'valid' => false, 244 | 'message' => 'Username cannot be empty' 245 | ], 422); 246 | } 247 | 248 | $isValid = Username::isValid($username); 249 | 250 | return response()->json([ 251 | 'valid' => $isValid, 252 | "username" => $username, 253 | 'message' => $isValid ? 'Username is valid and can be used' : 'Username is invalid or contains prohibited words' 254 | ]); 255 | } 256 | } 257 | ``` 258 | 259 | ### Using Service Directly 260 | 261 | For more control, you can use the UsernameService directly: 262 | 263 | ```php 264 | usernameService = $usernameService; 279 | } 280 | 281 | public function validateUsername(Request $request): JsonResponse 282 | { 283 | $username = $request->get('username'); 284 | $type = $request->get('type', 'all'); // 'all', 'pattern', or 'word' 285 | 286 | $isValid = false; 287 | 288 | // Validate based on type 289 | if ($type === 'pattern') { 290 | $isValid = $this->usernameService->isValid($username, 'pattern'); 291 | } elseif ($type === 'word') { 292 | $isValid = $this->usernameService->isValid($username, 'word'); 293 | } else { 294 | $isValid = $this->usernameService->isValid($username); 295 | } 296 | 297 | // Get error if validation fails 298 | $error = $this->usernameService->getLastError(); 299 | $exception = $this->usernameService->getLastException(); 300 | 301 | return response()->json([ 302 | 'valid' => $isValid, 303 | 'username' => $username, 304 | 'error' => $error, 305 | 'details' => $exception ? [ 306 | 'type' => $exception->getValidationType(), 307 | 'context' => $exception->getContext() 308 | ] : null, 309 | 'message' => $isValid ? 'Username is valid' : 'Username is invalid: ' . $error 310 | ]); 311 | } 312 | } 313 | ``` 314 | 315 | ## Adding Custom Categories 316 | 317 | To add custom prohibited word categories: 318 | 319 | 1. Add the category in configuration: 320 | 321 | ```php 322 | 'categories' => [ 323 | // Default categories... 324 | 325 | // Custom category 326 | 'trademark' => true, 327 | ], 328 | ``` 329 | 330 | 2. Create directory structure and word files: 331 | 332 | ```bash 333 | resources/vendor/username-guard/words/trademark/ 334 | ├── global.php (words for all languages) 335 | ├── en.php (English-specific words) 336 | └── id.php (Indonesian-specific words) 337 | ``` 338 | 339 | 3. Fill the files with arrays of prohibited words: 340 | 341 | ```php 342 | isValid($username); 364 | 365 | if (!$isValid) { 366 | // Get error message 367 | $errorMessage = $usernameService->getLastError(); 368 | 369 | // Get exception for more detailed information 370 | $exception = $usernameService->getLastException(); 371 | 372 | if ($exception) { 373 | $validationType = $exception->getValidationType(); // 'pattern' or 'word' 374 | $context = $exception->getContext(); // Additional information about the error 375 | 376 | // Handle based on validation type 377 | if ($validationType === 'pattern') { 378 | // Pattern error (length, characters, etc.) 379 | $rule = $context['rule'] ?? 'unknown'; 380 | // ... 381 | } elseif ($validationType === 'word') { 382 | // Prohibited word error 383 | $category = $context['category'] ?? 'unknown'; 384 | $locale = $context['locale'] ?? 'unknown'; 385 | // ... 386 | } 387 | } 388 | } 389 | ``` 390 | 391 | ## Customizing Error Messages 392 | 393 | You can customize error messages when using UsernameRule : 394 | 395 | ```php 396 | // Default error message 397 | $rule = new UsernameRule(); 398 | 399 | // Custom error message 400 | $rule = (new UsernameRule()) 401 | ->setMessage('Username is invalid or contains prohibited words.'); 402 | ``` 403 | 404 | ## License 405 | 406 | This package is licensed under the MIT License. 407 | 408 | ## Contributing 409 | 410 | Contributions are welcome! Please create issues or pull requests on the GitHub repository. 411 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aliziodev/laravel-username-guards", 3 | "description": "A comprehensive username filtering package for Laravel that helps filter profanity, adult content, illegal activities, gambling, spam, and religion abuse words", 4 | "type": "library", 5 | "keywords": [ 6 | "laravel", 7 | "username", 8 | "username-filter", 9 | "username-guard", 10 | "username-validator", 11 | "username-checker", 12 | "username-filtering", 13 | "username-validation", 14 | "filters", 15 | "profanity", 16 | "adult-content", 17 | "spam", 18 | "word-guard", 19 | "content-filter", 20 | "text-validator", 21 | "bad-words" 22 | ], 23 | "license": "MIT", 24 | "authors": [ 25 | { 26 | "name": "Alizio", 27 | "email": "aliziodev@gmail.com", 28 | "role": "Developer" 29 | } 30 | ], 31 | "require": { 32 | "php": "^8.1|^8.2", 33 | "illuminate/support": "^10.0|^11.0|^12.0", 34 | "illuminate/console": "^10.0|^11.0|^12.0", 35 | "illuminate/validation": "^10.0|^11.0|^12.0", 36 | "illuminate/translation": "^10.0|^11.0|^12.0", 37 | "illuminate/contracts": "^10.0|^11.0|^12.0" 38 | }, 39 | "require-dev": { 40 | "orchestra/testbench": "^8.0|^9.0", 41 | "pestphp/pest": "^2.34", 42 | "phpstan/phpstan": "^1.10", 43 | "laravel/pint": "^1.13" 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Aliziodev\\UsernameGuard\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Tests\\": "tests/" 53 | } 54 | }, 55 | "scripts": { 56 | "test": "vendor/bin/pest", 57 | "test-coverage": "vendor/bin/pest --coverage", 58 | "format": "vendor/bin/pint", 59 | "analyse": "vendor/bin/phpstan analyse", 60 | "check": [ 61 | "@format", 62 | "@analyse", 63 | "@test" 64 | ] 65 | }, 66 | "extra": { 67 | "laravel": { 68 | "providers": [ 69 | "Aliziodev\\UsernameGuard\\UsernameGuardServiceProvider" 70 | ], 71 | "aliases": { 72 | "Username": "Aliziodev\\UsernameGuard\\Facades\\Username" 73 | } 74 | } 75 | }, 76 | "minimum-stability": "dev", 77 | "prefer-stable": true, 78 | "config": { 79 | "sort-packages": true, 80 | "allow-plugins": { 81 | "pestphp/pest-plugin": true 82 | } 83 | }, 84 | "support": { 85 | "issues": "https://github.com/aliziodev/laravel-username-guards/issues", 86 | "source": "https://github.com/aliziodev/laravel-username-guards" 87 | } 88 | } -------------------------------------------------------------------------------- /config/username-guard.php: -------------------------------------------------------------------------------- 1 | env('APP_LOCALE', 'en'), 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Supported Languages 20 | |-------------------------------------------------------------------------- 21 | | 22 | | List of languages supported by the package. 23 | | 'global' is a REQUIRED locale that applies to all languages and must be included. 24 | | Add other languages here as needed. 25 | | 26 | | Note: The 'global' locale is mandatory and cannot be disabled or removed. 27 | | Example: ['global', 'en', 'id', 'es', 'fr'] 28 | | 29 | */ 30 | 'supported_locales' => [ 31 | 'global', // Global words (REQUIRED, applies to all languages) 32 | 'en', // English 33 | 'id', // Indonesian 34 | // 'es', // Spanish 35 | // 'fr', // French 36 | // 'de', // German 37 | ], 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Filtering Mode (Locale) 42 | |-------------------------------------------------------------------------- 43 | | 44 | | The word filtering system will work based on these two flags: 45 | | 46 | | - check_all_locales = true 47 | | -> Check all languages in 'supported_locales' 48 | | 49 | | - preferred_locale_only = true 50 | | -> Override check_all_locales and only use [APP_LOCALE + global] 51 | | 52 | | If both are false → only APP_LOCALE will be used (without global) 53 | | 54 | */ 55 | 'check_all_locales' => env('WORD_FILTER_CHECK_ALL_LOCALES', true), 56 | 'preferred_locale_only' => env('WORD_FILTER_PREFERRED_LOCALE_ONLY', false), 57 | 58 | /* 59 | |-------------------------------------------------------------------------- 60 | | Character Normalization 61 | |-------------------------------------------------------------------------- 62 | | 63 | | Enable or disable character normalization for word filtering. 64 | | When enabled, the system will normalize text by replacing common character 65 | | substitutions (like '0' to 'o', '1' to 'i', etc.) before checking for 66 | | prohibited words. This helps catch attempts to bypass filters using 67 | | character substitution. 68 | | 69 | | Example: When enabled, "k0nt0l" will be normalized to "kontol" for checking. 70 | | 71 | | - enabled: Enable/disable the entire normalization feature 72 | | - check_normalized_only: When true, only checks normalized text 73 | | When false, checks both original and normalized text 74 | | 75 | */ 76 | 'normalization' => [ 77 | 'enabled' => env('WORD_FILTER_NORMALIZATION_ENABLED', true), 78 | 'check_normalized_only' => env('WORD_FILTER_CHECK_NORMALIZED_ONLY', false), 79 | ], 80 | 81 | /* 82 | |-------------------------------------------------------------------------- 83 | | Word Categories 84 | |-------------------------------------------------------------------------- 85 | | 86 | | Define all word categories here. Each category can be enabled/disabled. 87 | | Default categories are pre-configured, but you can add custom ones. 88 | | 89 | | Structure: 90 | | 'category-name' => true|false 91 | | 92 | | Example custom categories: 93 | | 'hate-speech' => true, 94 | | 'trademark' => true, 95 | | 96 | | Note: When adding custom categories, make sure to create corresponding word files 97 | | in resources/vendor/username-guard/words/{category}/ with proper structure: 98 | | - Create a directory with your category name (e.g., trademark/) 99 | | - Add locale-specific PHP files (e.g., en.php, id.php) for each supported language 100 | | - Add global.php for words that apply to all languages 101 | | - Each PHP file should return an array of words 102 | | - Words should be in lowercase 103 | | - No special characters except hyphens and underscores 104 | | 105 | | Example structure for 'trademark' category: 106 | | trademark/ 107 | | ├── global.php (words for all languages) 108 | | ├── en.php (English-specific words) 109 | | └── id.php (Indonesian-specific words) 110 | | 111 | */ 112 | 113 | 'categories' => [ 114 | // Default categories 115 | 'profanity' => true, 116 | 'adult' => true, 117 | 'gambling' => true, 118 | 'religion_abuse' => true, 119 | 'illegal' => true, 120 | 'spam' => true, 121 | 'reserved' => true, 122 | 'hate' => true, 123 | 'scam' => true, 124 | 125 | // Optional categories 126 | 'political' => false, 127 | 'trending' => false, 128 | 129 | // Custom categories example: 130 | // 'hate-speech' => true, 131 | // 'trademark' => true, 132 | ], 133 | 134 | 135 | /* 136 | |-------------------------------------------------------------------------- 137 | | Validation Patterns 138 | |-------------------------------------------------------------------------- 139 | | 140 | | Pattern configuration for different validation scenarios. 141 | | These patterns are used to validate text format beyond word filtering. 142 | | 143 | | Available Patterns: 144 | | - username: For validating usernames 145 | | - domain or subdomain: For validating domain names 146 | | - basic: For basic text validation 147 | | 148 | */ 149 | 'patterns' => [ 150 | // Character sets 151 | 'sets' => [ 152 | 'alpha' => 'a-zA-Z', 153 | 'numeric' => '0-9', 154 | 'special' => '_-', 155 | 'extra' => '.', 156 | 'spaces' => '\s', 157 | ], 158 | 159 | // Common validation rules 160 | 'rules' => [ 161 | 'start_alpha' => '/^[a-zA-Z]/', // Must start with letter 162 | 'end_alphanumeric' => '/[a-z0-9]$/', // Must end with letter/number 163 | 'no_consecutive_dash' => '/[-]{2,}/', // No consecutive dashes 164 | 'no_consecutive_underscore' => '/[_]{2,}/', // No consecutive underscores 165 | 'no_consecutive_dot' => '/[.]{2,}/', // No consecutive dots 166 | 'no_consecutive_special_mix' => '/[._-]{2}/', // No mixed special characters (dot, underscore, dash) 167 | 'min_length' => 3, 168 | 'max_length' => 63, 169 | ], 170 | 171 | // Predefined pattern combinations 172 | 'presets' => [ 173 | 'basic' => [ 174 | 'allowed_chars' => '[^a-zA-Z0-9\s._-]', 175 | 'rules' => [ 176 | 'start_alpha', 177 | 'end_alphanumeric', 178 | 'no_consecutive_dash', 179 | 'no_consecutive_underscore', 180 | 'no_consecutive_dot', 181 | 'no_consecutive_special_mix' 182 | ], 183 | 'min_length' => 3, 184 | 'max_length' => 100, 185 | ], 186 | 'username' => [ 187 | 'allowed_chars' => '[^a-zA-Z0-9_-]', 188 | 'rules' => ['start_alpha', 'no_consecutive_dash', 'no_consecutive_underscore', 'no_consecutive_special_mix'], 189 | 'min_length' => 3, 190 | 'max_length' => 20, 191 | ], 192 | 'domain' => [ 193 | 'allowed_chars' => '[^a-z0-9-]', 194 | 'rules' => ['start_alpha', 'end_alphanumeric', 'no_consecutive_dash'], 195 | 'min_length' => 5, 196 | 'max_length' => 63, 197 | ], 198 | 199 | ], 200 | 201 | // Active patterns (enable/disable as needed) 202 | 'active' => [ 203 | 'basic' => false, // Disable basic pattern 204 | 'username' => true, // Enable username pattern 205 | 'domain' => false, // Disable domain or subdomain pattern 206 | ], 207 | ], 208 | 209 | /* 210 | |-------------------------------------------------------------------------- 211 | | Cache Configuration 212 | |-------------------------------------------------------------------------- 213 | | 214 | | Cache settings for better performance. 215 | | Recommended to enable in production only. 216 | | 217 | */ 218 | 'cache' => [ 219 | 'enabled' => env('WORD_FILTER_CACHE_ENABLED', true), // Enabled by default 220 | 'ttl' => env('WORD_FILTER_CACHE_TTL', 86400), // 24 hours 221 | 'store' => env('WORD_FILTER_CACHE_STORE', null), // Cache store: file, redis, etc 222 | ], 223 | ]; 224 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./app 15 | ./src 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /resources/words/adult/en.php: -------------------------------------------------------------------------------- 1 | info('Clearing Laravel Username Guard caches...'); 41 | 42 | 43 | $this->info('Clearing configuration cache...'); 44 | $this->callSilently('config:clear'); 45 | $this->line(' Configuration cache cleared.'); 46 | 47 | $this->info('Clearing application cache...'); 48 | $this->callSilently('cache:clear'); 49 | $this->line(' Application cache cleared.'); 50 | 51 | $this->info('Clearing package discovery cache...'); 52 | $this->callSilently('package:discover'); 53 | $this->line(' Package discovery cache cleared.'); 54 | 55 | $this->info('All caches have been cleared successfully!'); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Console/InstallCommand.php: -------------------------------------------------------------------------------- 1 | info('Installing Laravel Username Guard...'); 39 | 40 | // Publish configuration 41 | $this->publishConfiguration(); 42 | 43 | // Publish word resources 44 | $this->publishWordResources(); 45 | 46 | $this->info('Installation completed!'); 47 | $this->newLine(); 48 | $this->info('Please review the configuration file at:'); 49 | $this->line(' config/username-guard.php'); 50 | $this->newLine(); 51 | $this->info('Forbidden words can be found at:'); 52 | $this->line(' resources/vendor/username-guard/words/'); 53 | } 54 | 55 | /** 56 | * Publish the configuration file. 57 | */ 58 | protected function publishConfiguration(): void 59 | { 60 | $this->info('Publishing configuration...'); 61 | 62 | $this->callSilently('vendor:publish', [ 63 | '--tag' => 'username-guard-config', 64 | '--force' => true, 65 | ]); 66 | 67 | $this->line(' Configuration file published successfully.'); 68 | } 69 | 70 | /** 71 | * Publish the forbidden words resources. 72 | */ 73 | protected function publishWordResources(): void 74 | { 75 | $this->info('Publishing word resources...'); 76 | 77 | $this->callSilently('vendor:publish', [ 78 | '--tag' => 'username-guard-words', 79 | '--force' => true, 80 | ]); 81 | 82 | $this->line(' Word resources published successfully.'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Exceptions/UsernameGuardException.php: -------------------------------------------------------------------------------- 1 | validationType = $validationType; 60 | $this->username = $username; 61 | $this->context = $context; 62 | 63 | parent::__construct($message, $code, $previous); 64 | } 65 | 66 | /** 67 | * Get the validation type that caused the exception 68 | * 69 | * @return string 70 | */ 71 | public function getValidationType(): string 72 | { 73 | return $this->validationType; 74 | } 75 | 76 | /** 77 | * Get the username that caused the exception 78 | * 79 | * @return string 80 | */ 81 | public function getUsername(): string 82 | { 83 | return $this->username; 84 | } 85 | 86 | /** 87 | * Get additional context information 88 | * 89 | * @return array 90 | */ 91 | public function getContext(): array 92 | { 93 | return $this->context; 94 | } 95 | } -------------------------------------------------------------------------------- /src/Facades/Username.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | class Username extends Facade 44 | { 45 | /** 46 | * Get the registered name of the component. 47 | * 48 | * This method returns the binding key for the Username Guard service 49 | * in the Laravel service container. 50 | * 51 | * @return string The fully qualified class name of the service 52 | */ 53 | protected static function getFacadeAccessor(): string 54 | { 55 | return UsernameService::class; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Rules/UsernameRule.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | class UsernameRule implements ValidationRule 28 | { 29 | /** 30 | * The username validation service instance. 31 | * This service handles all validation logic including pattern matching 32 | * and prohibited words checking. 33 | * 34 | * @var UsernameService 35 | */ 36 | protected UsernameService $service; 37 | 38 | /** 39 | * Custom validation error message. 40 | * When set, this message overrides both pattern and prohibited words 41 | * error messages. 42 | * 43 | * @var string|null 44 | */ 45 | protected ?string $message = null; 46 | 47 | /** 48 | * Create a new username validation rule instance. 49 | * 50 | * @param string|null $message Optional custom error message that overrides default messages 51 | */ 52 | public function __construct(?string $message = null) 53 | { 54 | $this->service = app(UsernameService::class); 55 | $this->message = $message; 56 | } 57 | 58 | /** 59 | * Set a custom validation error message. 60 | * This message will override both pattern and prohibited words error messages. 61 | * 62 | * @param string $message The custom error message to use 63 | * @return self Returns the rule instance for method chaining 64 | */ 65 | public function setMessage(string $message): self 66 | { 67 | $this->message = $message; 68 | return $this; 69 | } 70 | 71 | /** 72 | * Validate the username value. 73 | * 74 | * The validation process follows these steps: 75 | * 1. Checks if the value is a string 76 | * 2. Validates the username pattern (length, allowed characters) 77 | * 3. Checks for prohibited words if pattern validation passes 78 | * 79 | * Each validation step has its own specific error message that can be 80 | * customized through translation files. 81 | * 82 | * @param string $attribute The name of the attribute being validated 83 | * @param mixed $value The value to validate 84 | * @param Closure $fail The callback to execute on validation failure 85 | */ 86 | public function validate(string $attribute, mixed $value, Closure $fail): void 87 | { 88 | if (!is_string($value)) { 89 | $fail('The :attribute must be a string.'); 90 | return; 91 | } 92 | 93 | // Pattern validation first 94 | if (!$this->service->isValid($value, 'pattern')) { 95 | $message = $this->message ?? $this->getErrorMessage($attribute, 'pattern'); 96 | $fail($message); 97 | return; 98 | } 99 | 100 | // Words validation second 101 | if (!$this->service->isValid($value, 'words')) { 102 | $message = $this->message ?? $this->getErrorMessage($attribute, 'words'); 103 | $fail($message); 104 | } 105 | } 106 | 107 | /** 108 | * Get error message based on validation type 109 | * 110 | * This method will try to get more specific error information 111 | * from the service if available, or use default messages if not. 112 | * 113 | * @param string $attribute The attribute being validated 114 | * @param string $type Validation type (pattern or words) 115 | * @return string The formatted error message 116 | */ 117 | protected function getErrorMessage(string $attribute, string $type): string 118 | { 119 | // Try to get the specific error message from the service 120 | $lastException = $this->service->getLastException(); 121 | 122 | if ($lastException instanceof UsernameGuardException) { 123 | // Use the exception message if available 124 | $validationType = $lastException->getValidationType(); 125 | $context = $lastException->getContext(); 126 | 127 | // Spesific message based on validation type and context 128 | if ($validationType === 'pattern') { 129 | $rule = $context['rule'] ?? ''; 130 | if ($rule === 'length') { 131 | return "The {$attribute} length is invalid. Please check the length requirements."; 132 | } elseif ($rule === 'allowed_chars') { 133 | return "The {$attribute} contains invalid characters."; 134 | } else { 135 | return "The {$attribute} format is invalid. {$lastException->getMessage()}"; 136 | } 137 | } elseif ($validationType === 'words') { 138 | $category = $context['category'] ?? ''; 139 | $word = $context['word'] ?? ''; 140 | if (!empty($category) && !empty($word)) { 141 | return "The {$attribute} contains prohibited word from category '{$category}'."; 142 | } else { 143 | return "The {$attribute} contains prohibited words."; 144 | } 145 | } 146 | } 147 | 148 | // Fallback message if no specific message is found 149 | if ($type === 'pattern') { 150 | return "The {$attribute} format is invalid. Please check the allowed characters and length requirements."; 151 | } else { 152 | return "The {$attribute} contains prohibited words."; 153 | } 154 | } 155 | 156 | /** 157 | * Get the default validation error message for prohibited words. 158 | * 159 | * This message is used when a username contains prohibited words and no 160 | * custom message has been set. The message can be customized through 161 | * 162 | * @param string $attribute The name of the attribute being validated 163 | * @return string The formatted error message 164 | * @deprecated Use getErrorMessage() as a replacement 165 | */ 166 | protected function getDefaultMessage(string $attribute): string 167 | { 168 | return "The {$attribute} contains prohibited words."; 169 | } 170 | 171 | /** 172 | * Get the validation error message for invalid patterns. 173 | * 174 | * This message is used when a username doesn't match the required pattern 175 | * (length, allowed characters, etc.) and no custom message has been set. 176 | * 177 | * @param string $attribute The name of the attribute being validated 178 | * @return string The formatted error message 179 | * @deprecated Use getErrorMessage() as a replacement 180 | */ 181 | protected function getPatternErrorMessage(string $attribute): string 182 | { 183 | return "The {$attribute} format is invalid. Please check the allowed characters and length requirements."; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Services/UsernameService.php: -------------------------------------------------------------------------------- 1 | config = $config ?: config('username-guard'); 105 | 106 | $this->categories = $this->config['categories'] ?? []; 107 | $this->customCategories = $this->config['custom_categories'] ?? []; 108 | $this->defaultCategories = $this->config['default_categories'] ?? []; 109 | 110 | $this->cacheConfig = $this->config['cache'] ?? ['enabled' => false]; 111 | $this->patterns = $this->loadPatterns($this->config['patterns'] ?? []); 112 | 113 | $this->locale = $this->config['default_locale'] ?? 'en'; 114 | 115 | // Validate configuration 116 | $this->validateConfiguration(); 117 | } 118 | 119 | /** 120 | * Validate basic configuration requirements 121 | * 122 | * This method checks if the essential configuration parameters are properly set: 123 | * - Ensures that a default locale is configured 124 | * - Verifies that the 'global' locale is included in supported_locales 125 | * 126 | * @throws UsernameGuardException If configuration validation fails 127 | */ 128 | protected function validateConfiguration(): void 129 | { 130 | // validate default locale 131 | if (empty($this->locale)) { 132 | throw new UsernameGuardException( 133 | 'Default locale is not configured', 134 | 'configuration', 135 | '', 136 | ['config' => 'default_locale'] 137 | ); 138 | } 139 | 140 | // Validate supported locales 141 | if (empty($this->config['supported_locales']) || !in_array('global', $this->config['supported_locales'])) { 142 | throw new UsernameGuardException( 143 | "The 'global' locale is required in supported_locales", 144 | 'configuration', 145 | '', 146 | ['config' => 'supported_locales'] 147 | ); 148 | } 149 | } 150 | 151 | /** 152 | * Set the validation locale for word checks 153 | * 154 | * Changes the active locale for subsequent word validations. 155 | * Affects which locale-specific word lists are used. 156 | * 157 | * @param string|null $locale The locale code to set (e.g., 'en', 'es') 158 | * @return self For method chaining 159 | */ 160 | public function setLocale(?string $locale): self 161 | { 162 | $this->locale = $locale; 163 | return $this; 164 | } 165 | 166 | /** 167 | * Load and structure validation patterns from configuration 168 | * 169 | * Organizes pattern configuration into structured arrays for validation use. 170 | * Includes pattern sets, rules, presets, and active status. 171 | * 172 | * @param array $patterns Raw pattern configuration array 173 | * @return array Structured patterns array 174 | */ 175 | protected function loadPatterns(array $patterns): array 176 | { 177 | if (empty($patterns)) { 178 | return []; 179 | } 180 | 181 | return [ 182 | 'sets' => $patterns['sets'] ?? [], 183 | 'rules' => $patterns['rules'] ?? [], 184 | 'presets' => $patterns['presets'] ?? [], 185 | 'active' => $patterns['active'] ?? [] 186 | ]; 187 | } 188 | 189 | /** 190 | * Retrieve prohibited words for a specific category with caching 191 | * 192 | * Gets words from the specified category, using cache if enabled. 193 | * Supports locale-specific word lists. 194 | * 195 | * @param string $category The word category to retrieve 196 | * @param string|null $locale Optional locale override 197 | * @return array List of prohibited words 198 | * @throws UsernameGuardException If loading words fails 199 | */ 200 | public function getWordsByCategory(string $category, ?string $locale = null): array 201 | { 202 | $locale = $locale ?? $this->locale ?? $this->config['default_locale']; 203 | $cacheKey = "username_guard_words_{$category}_{$locale}"; 204 | 205 | // Check if caching is enabled 206 | if ($this->cacheConfig['enabled']) { 207 | try { 208 | return Cache::remember( 209 | $cacheKey, 210 | $this->cacheConfig['ttl'] ?? 86400, 211 | fn() => $this->loadWords($category, $locale) 212 | ); 213 | } catch (\Exception $e) { 214 | throw new UsernameGuardException( 215 | "Failed to retrieve words from cache: {$e->getMessage()}", 216 | 'cache', 217 | '', 218 | ['category' => $category, 'locale' => $locale] 219 | ); 220 | } 221 | } 222 | 223 | return $this->loadWords($category, $locale); 224 | } 225 | 226 | /** 227 | * Validate a username with optional validation type 228 | * 229 | * Main validation method supporting both pattern and word validation. 230 | * Results are cached if caching is enabled. 231 | * 232 | * @param string $text The username to validate 233 | * @param string|null $type Validation type: 'pattern', 'words', or null for both 234 | * @return bool True if validation passes, false otherwise 235 | */ 236 | public function isValid(string $text, ?string $type = null): bool 237 | { 238 | // Reset error state 239 | $this->lastError = null; 240 | $this->lastException = null; 241 | 242 | // Cache validation result if enabled 243 | if ($this->cacheConfig['enabled']) { 244 | $cacheKey = "username_guard_validation_{$type}_" . md5($text); 245 | try { 246 | return Cache::remember( 247 | $cacheKey, 248 | $this->cacheConfig['ttl'] ?? 86400, 249 | fn() => $this->validate($text, $type) 250 | ); 251 | } catch (\Exception $e) { 252 | // Continue without cache 253 | } 254 | } 255 | 256 | return $this->validate($text, $type); 257 | } 258 | 259 | /** 260 | * Internal validation logic handler 261 | * 262 | * Performs the actual validation based on specified type. 263 | * Handles both pattern and word validation with error logging. 264 | * 265 | * @param string $text The username to validate 266 | * @param string|null $type Validation type 267 | * @return bool Validation result 268 | */ 269 | protected function validate(string $text, ?string $type = null): bool 270 | { 271 | try { 272 | // Pattern validation 273 | if ($type === null || $type === 'pattern') { 274 | if (!$this->validatePattern($text)) { 275 | $this->lastError = 'Pattern validation failed'; 276 | return false; 277 | } 278 | } 279 | 280 | // Words validation 281 | if ($type === null || $type === 'words') { 282 | if (!$this->validateWords($text)) { 283 | $this->lastError = 'Word validation failed'; 284 | return false; 285 | } 286 | } 287 | 288 | return true; 289 | } catch (UsernameGuardException $e) { 290 | $this->lastException = $e; 291 | $this->lastError = $e->getMessage(); 292 | return false; 293 | } catch (\Exception $e) { 294 | $this->lastError = $e->getMessage(); 295 | return false; 296 | } 297 | } 298 | 299 | /** 300 | * Validate username against prohibited words 301 | * 302 | * Checks if the username contains any prohibited words from active categories. 303 | * Case-insensitive matching is used. 304 | * 305 | * @param string $text The username to check 306 | * @return bool True if no prohibited words found, false otherwise 307 | * @throws UsernameGuardException If a prohibited word is found 308 | */ 309 | protected function validateWords(string $text): bool 310 | { 311 | try { 312 | // Get normalization settings from config 313 | $normalizationEnabled = $this->config['normalization']['enabled'] ?? true; 314 | $checkNormalizedOnly = $this->config['normalization']['check_normalized_only'] ?? false; 315 | 316 | // Normalize text if enabled 317 | $normalizedText = $normalizationEnabled ? $this->normalizeText($text) : $text; 318 | 319 | // Forbidden words validation 320 | foreach ($this->categories as $category => $enabled) { 321 | if (!$enabled) continue; 322 | 323 | $words = $this->getWordsByCategory($category); 324 | foreach ($words as $word) { 325 | // Check based on configuration 326 | $foundInOriginal = !$checkNormalizedOnly && stripos($text, $word) !== false; 327 | $foundInNormalized = $normalizationEnabled && stripos($normalizedText, $word) !== false; 328 | 329 | if ($foundInOriginal || $foundInNormalized) { 330 | throw new UsernameGuardException( 331 | "Username contains prohibited word from category '{$category}'", 332 | 'words', 333 | $text, 334 | ['category' => $category, 'word' => $word] 335 | ); 336 | } 337 | } 338 | } 339 | 340 | return true; 341 | } catch (UsernameGuardException $e) { 342 | // Re-throw UsernameGuardException 343 | throw $e; 344 | } catch (\Exception $e) { 345 | throw new UsernameGuardException( 346 | "Word validation error: {$e->getMessage()}", 347 | 'words', 348 | $text, 349 | ['error' => $e->getMessage()] 350 | ); 351 | } 352 | } 353 | 354 | /** 355 | * Validate username against active patterns 356 | * 357 | * Comprehensive pattern validation including: 358 | * - Length requirements 359 | * - Allowed characters 360 | * - Format rules (start/end requirements) 361 | * - Special character restrictions 362 | * Validates against all active pattern presets. 363 | * 364 | * @param string $text The username to validate 365 | * @return bool True if all pattern validations pass, false otherwise 366 | * @throws UsernameGuardException if a pattern validation fails 367 | */ 368 | public function validatePattern(string $text): bool 369 | { 370 | try { 371 | foreach ($this->patterns['active'] as $patternName => $isActive) { 372 | if (!$isActive) continue; 373 | 374 | $preset = $this->patterns['presets'][$patternName] ?? null; 375 | if (!$preset) continue; 376 | 377 | // Length validation 378 | $minLength = $preset['min_length'] ?? 3; 379 | $maxLength = $preset['max_length'] ?? 20; 380 | 381 | if (strlen($text) < $minLength || strlen($text) > $maxLength) { 382 | throw new UsernameGuardException( 383 | "Username length must be between {$minLength} and {$maxLength} characters", 384 | 'pattern', 385 | $text, 386 | ['pattern' => $patternName, 'rule' => 'length'] 387 | ); 388 | } 389 | 390 | // Character validation 391 | $allowedChars = $preset['allowed_chars'] ?? '[^a-zA-Z0-9._-]'; 392 | if (!preg_match('/^[' . str_replace('[^', '', str_replace(']', '', $allowedChars)) . ']+$/', $text)) { 393 | throw new UsernameGuardException( 394 | "Username contains invalid characters", 395 | 'pattern', 396 | $text, 397 | ['pattern' => $patternName, 'rule' => 'allowed_chars'] 398 | ); 399 | } 400 | 401 | // Format validation 402 | foreach ($preset['rules'] as $rule) { 403 | $pattern = $this->patterns['rules'][$rule]; 404 | if (str_contains($rule, 'no_consecutive_')) { 405 | if (preg_match($pattern, $text)) { 406 | throw new UsernameGuardException( 407 | "Username violates rule: {$rule}", 408 | 'pattern', 409 | $text, 410 | ['pattern' => $patternName, 'rule' => $rule] 411 | ); 412 | } 413 | } else { 414 | if (!preg_match($pattern, $text)) { 415 | throw new UsernameGuardException( 416 | "Username violates rule: {$rule}", 417 | 'pattern', 418 | $text, 419 | ['pattern' => $patternName, 'rule' => $rule] 420 | ); 421 | } 422 | } 423 | } 424 | } 425 | return true; 426 | } catch (UsernameGuardException $e) { 427 | // Re-throw UsernameGuardException 428 | throw $e; 429 | } catch (\Exception $e) { 430 | throw new UsernameGuardException( 431 | "Pattern validation error: {$e->getMessage()}", 432 | 'pattern', 433 | $text, 434 | ['error' => $e->getMessage()] 435 | ); 436 | } 437 | } 438 | 439 | /** 440 | * Load prohibited words from files with locale support 441 | * 442 | * Loads and merges word lists from multiple sources and locales. 443 | * Supports both package and published word lists. 444 | * Handles locale fallbacks and global words. 445 | * 446 | * @param string $category The word category to load 447 | * @param string $locale The locale to load words for 448 | * @return array Unique array of prohibited words 449 | * @throws UsernameGuardException if loading words fails 450 | */ 451 | protected function loadWords(string $category, string $locale): array 452 | { 453 | try { 454 | $words = []; 455 | $paths = [ 456 | __DIR__ . "/../resources/words", // Package path 457 | base_path("resources/vendor/username-guard/words") // Published path 458 | ]; 459 | 460 | // Determine locales based on configuration 461 | if ($this->config['preferred_locale_only']) { 462 | $locales = ['global', $locale]; 463 | } elseif ($this->config['check_all_locales']) { 464 | $locales = $this->config['supported_locales']; 465 | } else { 466 | $locales = [$locale]; 467 | } 468 | 469 | // Load words from each path and locale 470 | foreach ($paths as $basePath) { 471 | foreach ($locales as $currentLocale) { 472 | $path = "{$basePath}/{$category}/{$currentLocale}.php"; 473 | if (file_exists($path)) { 474 | $loadedWords = require $path; 475 | if (is_array($loadedWords)) { 476 | $words = array_merge($words, $loadedWords); 477 | // Optimization: Limit the number of words to 1000 478 | if (count($words) > 1000) { 479 | $words = array_unique($words); 480 | } 481 | } 482 | } 483 | } 484 | } 485 | 486 | return array_unique($words); 487 | } catch (\Exception $e) { 488 | throw new UsernameGuardException( 489 | "Failed to load words for category '{$category}': {$e->getMessage()}", 490 | 'words_loading', 491 | '', 492 | ['category' => $category, 'locale' => $locale] 493 | ); 494 | } 495 | } 496 | 497 | /** 498 | * Normalize text by replacing common character substitutions 499 | * 500 | * Replaces common character substitutions used to bypass word filters, 501 | * such as '0' to 'o', '1' to 'i', etc. This helps detect attempts to use 502 | * prohibited words with character substitutions. 503 | * 504 | * @param string $text The text to normalize 505 | * @return string The normalized text 506 | */ 507 | protected function normalizeText(string $text): string 508 | { 509 | $substitutions = [ 510 | '0' => 'o', 511 | '1' => 'i', 512 | '3' => 'e', 513 | '4' => 'a', 514 | '5' => 's', 515 | '6' => 'g', 516 | '7' => 't', 517 | '8' => 'b', 518 | '@' => 'a', 519 | '$' => 's', 520 | '+' => 't', 521 | '!' => 'i', 522 | 'z' => '2', 523 | '&' => 'a', 524 | '#' => 'h', 525 | '%' => 'p', 526 | '^' => 'c', 527 | '*' => 'x', 528 | '(' => 'c', 529 | ]; 530 | 531 | return str_replace(array_keys($substitutions), array_values($substitutions), strtolower($text)); 532 | } 533 | 534 | /** 535 | * Get the last error that occurred 536 | * 537 | * @return string|null 538 | */ 539 | public function getLastError(): ?string 540 | { 541 | return $this->lastError; 542 | } 543 | 544 | /** 545 | * Get the last exception that occurred 546 | * 547 | * @return UsernameGuardException|null 548 | */ 549 | public function getLastException(): ?UsernameGuardException 550 | { 551 | return $this->lastException; 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /src/UsernameGuardServiceProvider.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | class UsernameGuardServiceProvider extends ServiceProvider 22 | { 23 | /** 24 | * The commands to be registered. 25 | * 26 | * @var array 27 | */ 28 | protected $commands = [ 29 | Console\InstallCommand::class, 30 | Console\ClearCommand::class, 31 | ]; 32 | 33 | /** 34 | * Register package services. 35 | * 36 | * This method registers the configuration and singleton service 37 | * for username validation. 38 | */ 39 | public function register(): void 40 | { 41 | // Merge default configuration with application configuration 42 | $this->mergeConfigFrom( 43 | __DIR__ . '/../config/username-guard.php', 44 | 'username-guard' 45 | ); 46 | 47 | // Register UsernameService as singleton 48 | $this->app->singleton(UsernameService::class, function ($app) { 49 | $config = $app['config']->get('username-guard'); 50 | return new UsernameService($config); 51 | }); 52 | 53 | // Register the commands 54 | $this->commands($this->commands); 55 | } 56 | 57 | /** 58 | * Bootstrap package services. 59 | * 60 | * This method publishes configuration file and forbidden words resources 61 | * when the application is running in console mode. 62 | */ 63 | public function boot(): void 64 | { 65 | if ($this->app->runningInConsole()) { 66 | // Publish configuration file 67 | $this->publishes([ 68 | __DIR__ . '/../config/username-guard.php' => config_path('username-guard.php'), 69 | ], 'username-guard-config'); 70 | 71 | // Publish forbidden words files 72 | $this->publishes([ 73 | __DIR__ . '/../resources/words' => resource_path('vendor/username-guard/words'), 74 | ], 'username-guard-words'); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/Feature/UsernameServiceFeatureTest.php: -------------------------------------------------------------------------------- 1 | toBeInstanceOf(UsernameService::class); 11 | }); 12 | 13 | test('service can validate username with special characters', function () { 14 | $service = app(UsernameService::class); 15 | 16 | // Username with allowed characters 17 | expect($service->isValid('user_name'))->toBeTrue(); 18 | expect($service->isValid('user-name'))->toBeTrue(); 19 | 20 | // Username with disallowed characters 21 | expect($service->isValid('user@name'))->toBeFalse(); 22 | expect($service->isValid('user#name'))->toBeFalse(); 23 | }); 24 | 25 | test('service stores last error when validation fails', function () { 26 | $service = app(UsernameService::class); 27 | 28 | // Validate invalid username 29 | Config::set('username-guard.patterns.rules.min_length', 5); // Make sure min_length is large enough 30 | $service->isValid('a'); // Too short 31 | 32 | // Ensure error message is stored 33 | expect($service->getLastError())->not->toBeEmpty(); 34 | 35 | // Validate valid username 36 | $service->isValid('valid_username'); 37 | 38 | // Error message should be reset 39 | expect($service->getLastError())->toBeEmpty(); 40 | }); -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature'); 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 | set('username-guard.default_locale', 'en'); 33 | $app['config']->set('username-guard.supported_locales', ['global', 'en', 'id']); 34 | $app['config']->set('username-guard.preferred_locale_only', true); 35 | $app['config']->set('username-guard.check_all_locales', false); 36 | $app['config']->set('username-guard.categories', [ 37 | 'profanity' => true, 38 | 'adult' => true, 39 | 'spam' => false 40 | ]); 41 | $app['config']->set('username-guard.cache.enabled', false); 42 | } 43 | } -------------------------------------------------------------------------------- /tests/Unit/InvalidConfigurationTest.php: -------------------------------------------------------------------------------- 1 | '', 16 | 'supported_locales' => ['global', 'en'], 17 | ]; 18 | 19 | expect(fn() => new UsernameService($config)) 20 | ->toThrow(UsernameGuardException::class, 'Default locale is not configured'); 21 | }); 22 | 23 | test('missing global locale', function () { 24 | $config = [ 25 | 'default_locale' => 'en', 26 | 'supported_locales' => ['en', 'id'], 27 | ]; 28 | 29 | expect(fn() => new UsernameService($config)) 30 | ->toThrow(UsernameGuardException::class, "The 'global' locale is required in supported_locales"); 31 | }); -------------------------------------------------------------------------------- /tests/Unit/UsernameGuardExceptionTest.php: -------------------------------------------------------------------------------- 1 | 'length']; 17 | 18 | $exception = new UsernameGuardException($message, $validationType, $username, $context); 19 | 20 | expect($exception->getMessage())->toBe($message); 21 | expect($exception->getValidationType())->toBe($validationType); 22 | expect($exception->getUsername())->toBe($username); 23 | expect($exception->getContext())->toBe($context); 24 | }); 25 | 26 | test('exception with empty context', function () { 27 | $exception = new UsernameGuardException('Error', 'words', 'badword'); 28 | 29 | expect($exception->getMessage())->toBe('Error'); 30 | expect($exception->getValidationType())->toBe('words'); 31 | expect($exception->getUsername())->toBe('badword'); 32 | expect($exception->getContext())->toBe([]); 33 | }); -------------------------------------------------------------------------------- /tests/Unit/UsernameRuleTest.php: -------------------------------------------------------------------------------- 1 | serviceMock = null; 17 | }); 18 | 19 | afterEach(function () { 20 | Mockery::close(); 21 | }); 22 | 23 | test('valid username', function () { 24 | $serviceMock = Mockery::mock(UsernameService::class); 25 | $serviceMock->shouldReceive('isValid') 26 | ->with('validusername', 'pattern') 27 | ->andReturn(true); 28 | $serviceMock->shouldReceive('isValid') 29 | ->with('validusername', 'words') 30 | ->andReturn(true); 31 | $serviceMock->shouldReceive('getLastException') 32 | ->andReturn(null); 33 | 34 | app()->instance(UsernameService::class, $serviceMock); 35 | 36 | $rule = new UsernameRule(); 37 | $failCalled = false; 38 | 39 | $rule->validate('username', 'validusername', function() use (&$failCalled) { 40 | $failCalled = true; 41 | }); 42 | 43 | expect($failCalled)->toBeFalse(); 44 | }); 45 | 46 | test('invalid pattern', function () { 47 | $exception = new UsernameGuardException( 48 | 'Username length must be between 3 and 20 characters', 49 | 'pattern', 50 | 'ab', 51 | ['rule' => 'length'] 52 | ); 53 | 54 | $serviceMock = Mockery::mock(UsernameService::class); 55 | $serviceMock->shouldReceive('isValid') 56 | ->with('ab', 'pattern') 57 | ->andReturn(false); 58 | $serviceMock->shouldReceive('getLastException') 59 | ->andReturn($exception); 60 | 61 | app()->instance(UsernameService::class, $serviceMock); 62 | 63 | $rule = new UsernameRule(); 64 | $failMessage = null; 65 | 66 | $rule->validate('username', 'ab', function($message) use (&$failMessage) { 67 | $failMessage = $message; 68 | }); 69 | 70 | expect($failMessage)->not->toBeNull(); 71 | expect($failMessage)->toContain('length is invalid'); 72 | }); 73 | 74 | test('prohibited word', function () { 75 | $exception = new UsernameGuardException( 76 | 'Username contains prohibited word from category \'profanity\'', 77 | 'words', 78 | 'badword', 79 | ['category' => 'profanity', 'word' => 'bad'] 80 | ); 81 | 82 | $serviceMock = Mockery::mock(UsernameService::class); 83 | $serviceMock->shouldReceive('isValid') 84 | ->with('badword', 'pattern') 85 | ->andReturn(true); 86 | $serviceMock->shouldReceive('isValid') 87 | ->with('badword', 'words') 88 | ->andReturn(false); 89 | $serviceMock->shouldReceive('getLastException') 90 | ->andReturn($exception); 91 | 92 | app()->instance(UsernameService::class, $serviceMock); 93 | 94 | $rule = new UsernameRule(); 95 | $failMessage = null; 96 | 97 | $rule->validate('username', 'badword', function($message) use (&$failMessage) { 98 | $failMessage = $message; 99 | }); 100 | 101 | expect($failMessage)->not->toBeNull(); 102 | expect($failMessage)->toContain('prohibited word'); 103 | }); 104 | 105 | test('custom error message', function () { 106 | $serviceMock = Mockery::mock(UsernameService::class); 107 | $serviceMock->shouldReceive('isValid') 108 | ->with('ab', 'pattern') 109 | ->andReturn(false); 110 | 111 | app()->instance(UsernameService::class, $serviceMock); 112 | 113 | $customMessage = 'Invalid username'; 114 | $rule = new UsernameRule($customMessage); 115 | $failMessage = null; 116 | 117 | $rule->validate('username', 'ab', function($message) use (&$failMessage) { 118 | $failMessage = $message; 119 | }); 120 | 121 | expect($failMessage)->toBe($customMessage); 122 | }); 123 | 124 | test('set message method', function () { 125 | $serviceMock = Mockery::mock(UsernameService::class); 126 | $serviceMock->shouldReceive('isValid') 127 | ->with('ab', 'pattern') 128 | ->andReturn(false); 129 | 130 | app()->instance(UsernameService::class, $serviceMock); 131 | 132 | $customMessage = 'Invalid username'; 133 | $rule = new UsernameRule(); 134 | $rule->setMessage($customMessage); 135 | $failMessage = null; 136 | 137 | $rule->validate('username', 'ab', function($message) use (&$failMessage) { 138 | $failMessage = $message; 139 | }); 140 | 141 | expect($failMessage)->toBe($customMessage); 142 | }); -------------------------------------------------------------------------------- /tests/Unit/UsernameServiceTest.php: -------------------------------------------------------------------------------- 1 | 'en', 17 | 'supported_locales' => ['global', 'en', 'id'], 18 | 'preferred_locale_only' => true, 19 | 'check_all_locales' => false, 20 | 'categories' => [ 21 | 'profanity' => true, 22 | 'adult' => true, 23 | 'spam' => false 24 | ], 25 | 'custom_categories' => [], 26 | 'default_categories' => [ 27 | 'profanity' => true, 28 | 'adult' => true, 29 | 'spam' => true 30 | ], 31 | 'cache' => [ 32 | 'enabled' => false 33 | ], 34 | 'patterns' => [ 35 | 'sets' => [], 36 | 'rules' => [ 37 | 'no_consecutive_special_chars' => '/([._-])\\1+/', 38 | 'no_special_chars_at_start' => '/^[a-zA-Z0-9]/', 39 | 'no_special_chars_at_end' => '/[a-zA-Z0-9]$/', 40 | ], 41 | 'presets' => [ 42 | 'username' => [ 43 | 'min_length' => 3, 44 | 'max_length' => 20, 45 | 'allowed_chars' => '[a-zA-Z0-9._-]', 46 | 'rules' => [ 47 | 'no_consecutive_special_chars', 48 | 'no_special_chars_at_start', 49 | 'no_special_chars_at_end' 50 | ] 51 | ] 52 | ], 53 | 'active' => [ 54 | 'username' => true 55 | ] 56 | ] 57 | ]; 58 | 59 | $this->service = new UsernameService($config); 60 | }); 61 | 62 | test('valid username', function () { 63 | expect($this->service->isValid('validusername'))->toBeTrue(); 64 | expect($this->service->getLastError())->toBeNull(); 65 | expect($this->service->getLastException())->toBeNull(); 66 | }); 67 | 68 | test('username too short', function () { 69 | expect($this->service->isValid('ab', 'pattern'))->toBeFalse(); 70 | expect($this->service->getLastError())->not->toBeNull(); 71 | expect($this->service->getLastException())->toBeInstanceOf(UsernameGuardException::class); 72 | 73 | $exception = $this->service->getLastException(); 74 | expect($exception->getValidationType())->toBe('pattern'); 75 | expect($exception->getUsername())->toBe('ab'); 76 | expect($exception->getContext())->toHaveKey('rule'); 77 | expect($exception->getContext()['rule'])->toBe('length'); 78 | }); 79 | 80 | test('username invalid characters', function () { 81 | expect($this->service->isValid('invalid@username', 'pattern'))->toBeFalse(); 82 | expect($this->service->getLastError())->not->toBeNull(); 83 | expect($this->service->getLastException())->toBeInstanceOf(UsernameGuardException::class); 84 | 85 | $exception = $this->service->getLastException(); 86 | expect($exception->getValidationType())->toBe('pattern'); 87 | expect($exception->getUsername())->toBe('invalid@username'); 88 | expect($exception->getContext())->toHaveKey('rule'); 89 | expect($exception->getContext()['rule'])->toBe('allowed_chars'); 90 | }); 91 | 92 | test('username special char at start', function () { 93 | expect($this->service->isValid('_username', 'pattern'))->toBeFalse(); 94 | expect($this->service->getLastError())->not->toBeNull(); 95 | expect($this->service->getLastException())->toBeInstanceOf(UsernameGuardException::class); 96 | 97 | $exception = $this->service->getLastException(); 98 | expect($exception->getValidationType())->toBe('pattern'); 99 | expect($exception->getUsername())->toBe('_username'); 100 | }); 101 | 102 | test('username consecutive special chars', function () { 103 | expect($this->service->isValid('user__name', 'pattern'))->toBeFalse(); 104 | expect($this->service->getLastError())->not->toBeNull(); 105 | expect($this->service->getLastException())->toBeInstanceOf(UsernameGuardException::class); 106 | 107 | $exception = $this->service->getLastException(); 108 | expect($exception->getValidationType())->toBe('pattern'); 109 | expect($exception->getUsername())->toBe('user__name'); 110 | }); 111 | 112 | test('error state reset', function () { 113 | // First, create an error 114 | expect($this->service->isValid('_invalid', 'pattern'))->toBeFalse(); 115 | expect($this->service->getLastError())->not->toBeNull(); 116 | expect($this->service->getLastException())->not->toBeNull(); 117 | 118 | // Then successful validation 119 | expect($this->service->isValid('validuser'))->toBeTrue(); 120 | expect($this->service->getLastError())->toBeNull(); 121 | expect($this->service->getLastException())->toBeNull(); 122 | }); 123 | 124 | test('validate configuration', function () { 125 | // Test with valid configuration already done in setUp() 126 | expect(true)->toBeTrue(); // Valid configuration doesn't throw exception 127 | }); --------------------------------------------------------------------------------