├── .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 | [](https://packagist.org/packages/aliziodev/laravel-username-guards)
4 | [](https://packagist.org/packages/aliziodev/laravel-username-guards)
5 | [](https://packagist.org/packages/aliziodev/laravel-username-guards)
6 | [](https://packagist.org/packages/aliziodev/laravel-username-guards)
7 | [](https://packagist.org/packages/aliziodev/laravel-username-guards)
8 | [](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 | });
--------------------------------------------------------------------------------