nevadskiy/laravel-uuid
A Laravel package that adds UUID support to Eloquent models, making it easy to use universally unique identifiers as primary keys. It provides traits and helpers for generating and assigning UUIDs automatically, simplifying setup and integration with existing models.
1. Install and inspect the source first.
composer require nevadskiy/laravel-uuid
ls vendor/nevadskiy/laravel-uuid/src
The package is intentionally tiny — one trait, no service provider config to speak of. Before writing a line of application code, open src/ and read the trait top to bottom (it is usually <100 lines). You want to confirm three things: the trait's fully-qualified name (e.g. Nevadskiy\Uuid\HasUuid — namespace may include Concerns), the boot method name (bootHasUuid), and the method it calls to generate the value (generateUuid() / Str::uuid()). Everything else in this assessment follows from those three facts.
2. Choose your key strategy before touching migrations.
Two shapes are common, and the package supports the first out of the box:
$table->uuid('id')->primary(), no auto-increment column at all.id stays internal, a uuid column is exposed in URLs/APIs.Decide now; retrofitting a primary key on a table with data is painful.
3. First use case: a users table with UUID keys.
// migration
Schema::create('users', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
$table->string('email')->unique();
$table->timestamps();
});
// app/Models/User.php
use Illuminate\Database\Eloquent\Model;
use Nevadskiy\Uuid\HasUuid; // verify FQCN in vendor/
class User extends Model
{
use HasUuid;
protected $guarded = [];
}
$user = User::create(['name' => 'Ada', 'email' => '[email protected]']);
$user->id; // "9b2f..." — generated on the `creating` event
User::find($user->id); // works unchanged; whereKey() already handles the string
Route model binding, findOrFail, whereKey, and API resources all work with no further changes — Eloquent only cares that $keyType is string and $incrementing is false, which the trait sets for you.
Apply the trait at the base model, not per-model. If every model gets a UUID, put the trait on an abstract App\Models\Model (or a shared BaseModel) so new models inherit the behaviour by default and nobody forgets it.
Keep factories ignorant of the key. Factories should never set id. Because the value is generated on creating, User::factory()->create() just works; ->make() returns a model with a null key until persisted. If a test needs a deterministic key, pass it explicitly and the trait should defer to the provided value (verify this — most implementations do a ??= style assignment; if yours overwrites, that's a bug to patch locally or guard with Model::withoutEvents).
Foreign keys must match the type exactly. With UUID PKs, use foreignUuid() (Laravel 8+) or $table->uuid('user_id'); a unsignedBigInteger FK will fail at migration time, and worse, a char(36)/varchar(255) mismatch will silently break index usage on MySQL. Keep collation and length identical on both sides.
Related-model creation is the sweet spot.
$order = $user->orders()->create([...]); // FK filled from the UUID parent
Pivot tables (user_id, role_id) all become uuid columns — worth a single pass over database/migrations early rather than discovering it one failing test at a time.
Wrap UUIDs at the boundary. Cast/validate incoming route or request values with the uuid validation rule before they hit the query builder, so a malformed 36-char string doesn't reach MySQL and produce a driver-level error:
$request->validate(['user' => ['required', 'uuid']]);
Expose both keys when you keep an integer PK. If you chose the "public UUID" pattern, add getRouteKeyName(): string { return 'uuid'; } on the model and $table->uuid('uuid')->unique() — this gives you fast clustered inserts plus opaque URLs, and is the pattern most teams actually want for user-facing resources.
Ordered UUIDs for high-volume tables. Random v4 keys fragment InnoDB's clustered index on insert. For append-heavy tables (events, logs, messages), check whether the package exposes an ordered variant or override the generator to emit a time-ordered UUID (v7). The trait's generateUuid() is the extension point — overriding it in your model is a one-liner and keeps the rest of the package's wiring intact.
Laravel 9+ already ships HasUuids. Illuminate\Database\Eloquent\Concerns\HasUuids (and HasUniqueStringIds in 10+/11+) covers most of this ground, including newUniqueId() and uniqueIds(). The honest assessment: on a modern Laravel app this package is largely redundant. Its value is on Laravel 6/7/8, or as a 40-line readable reference if you'd rather vendor the behaviour into a base model. If you adopt it on Laravel 10+, do not also use HasUuids — the two traits both define key-type/incrementing behaviour and will fight over the boot events.
Trait boot methods are name-sensitive. bootHasUuid only fires if the trait is literally named HasUuid. If you alias it (use HasUuid { ... }) or wrap it in your own trait, the boot hook silently never registers and you get Integrity constraint violation: id cannot be null on insert. Symptom to recognise: everything works in make() tests, nothing works on create().
A custom boot() on the model can break it. If a model overrides static::boot() and forgets parent::boot(), initialisation of trait hooks stops. Same for models that override booted().
$incrementing/$keyType set by the trait are easily clobbered. Declaring public $incrementing = true; on the model (or a parent) after the trait can win depending on declaration order. If save() returns the model with a numeric id, check these two properties first with dd($model->getKeyType(), $model->getIncrementing()).
Debugging "the UUID is empty": in order, check (1) trait is actually used on the model, (2) boot hook registered — User::getEventDispatcher()->hasListeners('eloquent.creating: App\Models\User'), (3) $guarded/$fillable isn't blocking if you pass a value, (4) you aren't inside Model::withoutEvents(). Seeding and bulk insert() bypass model events entirely — DB::table('users')->insert([...]) will not generate a UUID, and will fail on a NOT NULL PK. Use Model::create() in seeders, or generate keys manually in bulk inserts.
firstOrCreate and updateOrCreate still need real columns. They compare on your lookup attributes, not on id, so they behave normally — but the create branch is where the UUID is minted. Beware updateOrCreate with a UUID-shaped id in the attributes array; it becomes an explicit key assignment.
Soft deletes, upsert, and insertOrIgnore all skip events. Anything that doesn't instantiate a model is outside the trait's reach.
Testing tip: assertDatabaseHas('users', ['id' => $user->id]) is fine, but avoid hardcoding UUIDs in test fixtures — use Str::uuid()->toString() or capture the model's key. Also cast uuid columns in $casts only if you want a UuidInterface; the default string behaviour is what route binding expects.
Performance reality check: 36-byte string PKs make every secondary index wider and every FK join slower than a bigint. At small-to-medium scale it's irrelevant; past tens of millions of rows, the "integer PK + unique UUID column" pattern is the pragmatic escape hatch, and it costs you nothing to adopt from day one.
Extension point: rather than editing vendor/, subclass or override generateUuid() on the model, or bind your own generator if the package resolves one from the container. Keep any local patches in a single trait so a future swap to Laravel's native HasUuids is a one-line change per model.
How can I help you explore Laravel packages today?