x-laravel / username
Usernames for Eloquent models with configurable validation, reserved names, generation and a history that keeps old profile links working.
Requires
- php: ^8.4
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/support: ^13.0
- illuminate/validation: ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^11.5 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 17:42:29 UTC
README
Username handling for Laravel in one place: a configurable format rule, a reserved-name rule and a generator that turns any text — a name, an e-mail prefix, a social nickname — into a free username that passes the format rule.
Requirements
| Package version | PHP | Laravel |
|---|---|---|
| 1.x | 8.4+ | 13 |
Installation
composer require x-laravel/username php artisan vendor:publish --tag=username-migrations php artisan migrate
Publish the config to change the defaults:
php artisan vendor:publish --tag=username-config
Preparing the Models
Implement HasUsername by telling it what to build a username from, and use the InteractsWithUsername
trait:
use XLaravel\Username\Concerns\InteractsWithUsername; use XLaravel\Username\Contracts\HasUsername; class User extends Authenticatable implements HasUsername { use InteractsWithUsername; public function usernameSource(): string { return $this->first_name . ' ' . $this->last_name; } }
Configuration
| Key | Default | Meaning |
|---|---|---|
rules.min |
2 |
Minimum length |
rules.max |
30 |
Maximum length |
rules.dots |
true |
Allow dots (never at the edges or doubled) |
rules.hyphens |
true |
Allow hyphens |
rules.underscores |
true |
Allow underscores |
rules.uppercase |
false |
Allow uppercase letters |
rules.numeric |
true |
Allow usernames made of digits only |
fallback |
'user' |
Base the generator uses when nothing usable is left of its input |
reserved |
see the file | Names the reserved-name rule rejects |
connection |
null |
Connection of the history table |
history.enabled |
true |
Keep and resolve old usernames |
history.reserve_for |
90 |
Days an old username stays reserved for its owner |
history.table |
'username_history' |
Name of the history table |
history.model |
UsernameHistory::class |
History model; must extend the package's |
prune.enabled |
true |
Schedule the pruning of old history rows |
prune.after |
365 |
Days after which history rows are pruned; null keeps them forever |
prune.schedule |
'daily' |
A cron expression, or a frequency method with its arguments: 'dailyAt:03:30', 'twiceDaily:1,13', 'weeklyOn:1,04:00' |
prune.chunk |
1000 |
Rows deleted by each pruning query |
Put the history table on the same connection as your models, so a username change and its history row are written in one transaction.
The Format
Out of the box a username:
- contains only lowercase English letters, digits, hyphens (
-), underscores (_) and dots (.); - does not start or end with a dot and has no two dots in a row;
- is 2 to 30 characters long.
Every part of it is a rules key in the config. Letters outside the English alphabet are never
allowed, so a username always fits in a URL without encoding.
use XLaravel\Username\Rules\Username; $request->validate([ 'username' => ['required', new Username, 'unique:users'], ]);
The rule reads the config; override any part for a single field:
Username::make()->min(3)->max(20)->dots(false)->numeric(false)->uppercase();
With uppercase off the rule rejects capitals rather than lowering them. Lowercase the input
before validation if users may type capitals, for example in a form request:
protected function prepareForValidation(): void { $this->merge(['username' => strtolower((string) $this->input('username'))]); }
The error message names exactly the characters that are allowed, so it stays correct when you change the config.
Reserved Names
use XLaravel\Username\Rules\NotReserved; $request->validate([ 'username' => ['required', new Username, new NotReserved(['acme']), 'unique:users'], ]);
The list comes from the reserved config key; names passed to the rule are added to it. Matching
ignores case, dots, hyphens and underscores, so ad.min and Ad-Min count as admin.
Generating Usernames
use App\Models\User; use Illuminate\Support\Str; use XLaravel\Username\UsernameGenerator; $username = UsernameGenerator::generate( Str::before($email, '@'), fn (string $candidate) => User::where('username', $candidate)->exists(), );
generate() normalizes the text, then appends -2, -3, … until the candidate is long enough, not
reserved and not taken — trimming the base so the result never exceeds the maximum length. The
number is joined with a hyphen, or with an underscore when hyphens are off. The result always passes
the format rule.
normalize() does the first half on its own: it transliterates to ASCII (Turkish letters such as
ı, ş and ğ included), lowercases unless uppercase is allowed, turns runs of disallowed
characters into a hyphen (an underscore when hyphens are off), collapses repeated dots, trims dots
and separators from the edges and cuts the result to the maximum length.
UsernameGenerator::normalize('Iğdır Şükrü'); // igdir-sukru UsernameGenerator::normalize('ahmet.can+promo'); // ahmet.can-promo
When digit-only usernames are not allowed, generate() puts the fallback in front of them, so
12345 becomes user12345.
Usernames on the Model
On every save the trait:
- generates a free username from
usernameSource()when the column is empty; - lowercases a username that was set or changed, unless uppercase is allowed.
A username that did not change is left alone, so existing rows are never rewritten behind your back.
The trait works on the username column. To use another one, set a property on the model:
protected string $usernameColumn = 'handle';
Override usernameColumn() instead when the column has to be decided at runtime.
Availability is checked with usernameTaken(), which ignores global scopes (soft-deleted rows still
count) and the model's own row; override it to scope the check differently.
Username History
When a username changes, the old one is written to the history table, so old profile links keep finding the account and nobody else can take the name right away. Looking an account up by username checks both the current username and the history:
$user = User::findByUsername($username); $user = User::query()->whereUsername($username)->firstOrFail();
Route model binding follows the history too when the route key is the username column.
An old username stays reserved for its owner for reserve_for days: no other account can take it,
and the generator skips it. The owner can always go back to it. When the period ends another
account may take the name; the history row is then removed and the name belongs to the new account.
A model can override the period with a $usernameReservedFor property, or opt out of the history
with $usernameHistory = false.
Laravel's unique rule cannot see the history table. Use UniqueUsername, which checks both live
and reserved usernames and ignores the given account's own names:
use XLaravel\Username\Rules\UniqueUsername; $request->validate([ 'username' => ['required', new Username, new UniqueUsername($request->user())], ]);
Pass the class name instead of an instance for a new account.
String Aliases
$request->validate([ 'username' => ['required', 'username', 'not_reserved_username'], ]);
Both aliases use the config. not_reserved_username also takes extra names as parameters:
not_reserved_username:acme,beta. Laravel resolves the aliases' messages like any extended rule, so
add lines to your application's lang/xx/validation.php, or pass inline messages.
Concurrent Saves
Two requests can pick the same free username at the same moment. When the trait generated the
username and the insert then fails on the username's unique index, it generates a new one and tries
again, up to three times. A username that was given rather than generated is never retried — the
unique validation rule is the place for that. Inside an open transaction the insert runs in a
savepoint, so a failed attempt does not break the surrounding transaction.
The retry lives in the trait's save() method. If your model overrides save(), call
parent::save() from it to keep the retry.
Deleting and Pruning
Force-deleting an account removes its history. Soft-deleting keeps it.
Rows older than prune.after days are deleted by Laravel's model:prune command. The package
schedules it for the history table itself, daily by default; set prune.schedule to another
frequency such as 'hourly' or 'dailyAt:03:30', or to a cron expression. Nothing is scheduled when
prune.enabled is off, the history is off or prune.after is null. Run it by hand with:
php artisan model:prune --model="XLaravel\Username\Models\UsernameHistory"
A pruned row no longer resolves. Keep prune.after longer than reserve_for; a row that is pruned
earlier also ends its reservation.
Reacting to a Changed Username
When a saved username changes, the trait dispatches XLaravel\Username\Events\UsernameChanged with
the model and the old and new usernames — handy for redirecting old profile links, clearing caches
or writing an audit entry:
use XLaravel\Username\Events\UsernameChanged; Event::listen(function (UsernameChanged $event) { ProfileRedirect::create(['from' => $event->old, 'to' => $event->new]); });
The event is dispatched only after the surrounding database transaction commits, so a rolled-back change is never announced. Giving a new model its first username does not count as a change.
The trait also adds two model events, so the change can be handled in an observer:
class UserObserver { public function usernameChanging(User $user): bool { return ! $user->isLocked(); } public function usernameChanged(User $user): void { $old = $user->getOriginal('username'); } }
usernameChangingruns before the row is written. Returnfalseto cancel the save.usernameChangedruns right after the row is written, inside the transaction if there is one.getOriginal()still returns the old username at that point.
Both can also be registered on the model, like any Eloquent event:
User::usernameChanged(fn (User $user) => Cache::forget("profile:{$user->getOriginal('username')}"));
Use the observer events for work that belongs with the save, and the UsernameChanged event for work
that must only happen once the change is committed.
Customising the Messages
The rules come with Turkish and English messages:
php artisan vendor:publish --tag=username-translations
Testing
composer install
composer test
composer analyse
License
Open source software licensed under the MIT license.