x-laravel/username
A Laravel package for adding username support to Eloquent models, with configurable username generation, validation rules, uniqueness checks, and helper methods for registration and profile flows. Lightweight and easy to configure.
The package is a complete username lifecycle layer for Eloquent models: format validation, reserved-name protection, generation from free text, uniqueness with history, and automatic pruning. Look at the published config file first — nearly every behaviour (format, reserved names, history retention, pruning cadence, connection, table/model names) is a config key.
Minimal setup:
Install and publish assets:
composer require <vendor>/<package>
php artisan vendor:publish --tag=<package>-config
php artisan vendor:publish --tag=<package>-migrations
php artisan migrate
Read config/<package>.php end to end before writing code. The format rule (character classes, min/max length, separators allowed) and the reserved-name list are both driven from here, so the config is effectively your product spec.
Opt a model in — this is the only wiring step:
class User extends Authenticatable implements HasUsername
{
use InteractsWithUsername;
}
The interface is how the model declares what its username is built from (return the name, or the email local-part, or a display name). The trait supplies the rest: generating a missing username, lowercasing a changed one, and retrying on a unique-index race.
Confirm your users table has a unique index on the username column. The trait's race-condition retry only makes sense because the database enforces uniqueness; without the index you get silent duplicates instead of a retry.
First use case — registration without asking the user for a username:
$user = User::create([
'name' => $request->name,
'email' => $request->email,
// username intentionally omitted — the trait generates it
]);
That is the shortest path to value. Everything else (history, events, pruning) is already on by default through config, so a fresh install immediately gives you "old usernames still resolve" and "old usernames are still blocked for their owner."
Prefer the generator over ad-hoc slugging. UsernameGenerator::normalize() folds arbitrary text into a valid-format candidate; generate() additionally resolves collisions and checks reserved names. Use normalize() when you want a deterministic preview (e.g. showing the user what they'll get), and generate() when you need something that actually persists.
Validation in form requests. Three rules, three jobs — compose them rather than reimplementing:
'username' => [
'required',
'string',
new Rules\Username, // format only
new Rules\NotReserved, // reserved-name blocklist
new Rules\UniqueUsername, // unique *and* aware of reserved history
],
UniqueUsername is the one that matters on the update path: because history rows stay reserved for their owner for a configurable period, a username that looks free in the users table may still be rejected. Using unique:users,username here will produce a rule that disagrees with the package — always use UniqueUsername.
Self-service username changes. Let validation pass, assign the attribute, save; the trait lowercases the value, writes the old one into history, and dispatches events. The pattern that works well is: validate → assign → save() → react to UsernameChanged rather than doing side effects inline. Because the event is dispatched after commit, listeners can safely queue jobs (re-send marketing emails, update a search index, notify followers) without seeing an uncommitted row.
History as a feature, not a side effect. Route model binding, whereUsername() and findByUsername() all resolve old usernames automatically. That means old links keep working with zero extra code — an easy win to advertise to users. It also means you should not build your own "previous usernames" table; query the configured history model instead.
Model events vs. the event class. Use usernameChanging / usernameChanged for anything that must run in the same request and can veto or observe the change; use Events\UsernameChanged for cross-cutting, post-commit work. Mixing the two is the usual source of "notification fired but the change rolled back."
Multi-tenant / multi-database setups. The configurable connection, history table and history model exist for this. Point the connection at the tenant DB and keep the same config shape; do not override the table per-tenant unless you also override it consistently in the history model.
Scheduling and pruning. Pruning rides model:prune and the package registers its own schedule with configurable frequency, age and chunk size. In practice: keep the package's schedule entry (don't disable schedule:run), and tune chunk size down if history rows are wide or the table is hot. In tests, disable pruning or set a long age so fixtures don't evaporate mid-suite.
Customising the format per form. Every part of the Rules\Username rule is configurable on the rule instance as well as in config. Good pattern: keep the config as the default, and pass explicit parameters on the rule only where a specific flow genuinely differs (e.g. an imported legacy account), so the invariant "anything generated passes the rule" holds.
Rules\Username via config after users exist, generate() should still produce conforming values, but previously stored usernames may no longer pass. Decide whether you validate existing rows before deploying a config change.NotReserved is backed by a config list. Adding a name does not retroactively free or seize existing accounts; it only affects new assignments and changes. Removing a name doesn't release history reservations either.UniqueUsername is for.users route key and a custom binding, confirm old usernames resolve as expected — binding goes through the history path, so a stale cached route or a manually written User::where('username', ...) in a controller bypasses it.after commit semantics in tests. UsernameChanged fires after commit; inside DatabaseTransactions tests the commit never happens the way you expect. Use RefreshDatabase (or Event::fake()) rather than asserting on listeners inside a rolled-back transaction.php artisan config:cache in deploy, and remember the schedule is registered at boot — changing prune frequency requires a deploy, not just a config tweak.username and not_reserved_username aliases ship with English and Turkish messages. Publish/override the lang files if you support other locales, rather than writing message strings in form requests.UsernameChanged listener doing an assignment and re-saving? history pruning age set to zero/very short in a local .env?How can I help you explore Laravel packages today?