jamesh/laravel-uuid
Adds UUID primary keys to Laravel Eloquent models. A lightweight trait generates a UUID on creation, supports lookups by UUID, and makes models route-key ready, so you can replace auto-incrementing IDs without rewriting your queries or controllers.
Step 0 — check compatibility before you install. The package is archived (last release Feb 2022), has 20 stars and zero dependents. Open its composer.json and confirm the illuminate/database / illuminate/support constraints cover your Laravel version. It targets Laravel 6–8. If you are on Laravel 9+, stop here — Illuminate\Database\Eloquent\Concerns\HasUuids ships in core and does the same job (plus ordered UUIDs and uniqueIds() support for secondary columns). Installing a two-year-old abandoned trait on a modern app is a net negative.
If you are on Laravel 6–8:
composer require jamesh/laravel-uuid
No service provider config, no publishable config file — the whole package is one trait. Spend your first minute reading it:
cat vendor/jamesh/laravel-uuid/src/HasUuid.php
It is ~30 lines and boils down to three things: getIncrementing() => false, getKeyType() => 'string', and a bootHasUuid() hook that fills the key with a UUID string inside the creating event. Knowing that is 90% of understanding the package.
Wire up a model. The migration must declare the key as a UUID before the model works:
Schema::create('posts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
$table->timestamps();
});
use JamesH\Uuid\HasUuid;
class Post extends Model
{
use HasUuid;
protected $guarded = [];
}
First use case — verify end to end in tinker:
$post = Post::create(['title' => 'Hello']);
$post->id; // "9c1f0e2a-..." (string, populated before INSERT)
$post->incrementing; // false
$post->getKeyType(); // "string"
If id is null after create(), the creating listener isn't firing — usually because you called Post::insert() or a query-builder insert instead of create().
New tables. Use $table->uuid('id')->primary() for every model that adopts the trait, and $table->foreignUuid('post_id')->constrained() for relations. Mixing $table->id() and $table->uuid('id') across tables is fine — the trait is opt-in per model, not global.
Relations and pivot tables. Foreign key columns must be uuid, never unsignedBigInteger. Pivot tables need $table->uuid('post_id') on both sides plus a composite primary key. Any morphs()/nullableMorphs() needs the manual variant (uuidMorphs('commentable') exists in Laravel 7+; on Laravel 6 write the two columns yourself).
Route model binding. Works unchanged because the key is a string; Route::get('/posts/{post}') resolves via whereKey($uuid). Keep getRouteKeyName() as id. Bind with scoped bindings ({post:uuid} is redundant, but ->scopeBindings() is worth it for nested resources).
Reusing one concern across a codebase. Don't scatter use HasUuid blindly — wrap it in your own base model or a local UsesUuidKey trait that composes the package trait. That gives you one seam to swap in Laravel's native HasUuids later without touching 40 models:
trait UsesUuidKey
{
use \JamesH\Uuid\HasUuid;
// future: use \Illuminate\Database\Eloquent\Concerns\HasUuids;
}
Custom UUID flavour. If you need ordered UUIDs (strongly recommended for PK performance — see below), override the generator in your model rather than forking the package:
protected static function bootHasUuid()
{
static::creating(function ($model) {
if (empty($model->{$model->getKeyName()})) {
$model->{$model->getKeyName()} = (string) Str::orderedUuid();
}
});
}
Defining bootHasUuid() in the model shadows the trait's version — that's the intended extension point and it's cleaner than editing vendor code.
Factories and seeders. Post::factory()->create() gets a UUID automatically. Bulk seeders that call Post::insert([...]) do not — they bypass Eloquent events and hit a NOT NULL violation. Loop over create(), or generate Str::uuid() explicitly in the array you pass to insert().
API layer. Cast nothing special: $post->id is already a string, so JsonResource and toArray() emit it correctly. Cache keys built from $post->id work, but remember they're opaque — don't build anything on ordering. Use created_at (or an ordered UUID) for sorting.
Retrofitting an existing integer-keyed model is the hard case and usually not worth it: you need a column swap, backfill (Str::uuid() per row), FK rewrites on every referencing table, and a deploy window. If you must, treat it as a full data migration, not a trait change.
Illuminate\Database\Eloquent\Concerns\HasUuids (Laravel 9+) or generate the UUID in a creating observer yourself — it's eight lines.use both the package trait and Laravel's native HasUuids, you get a "trait method collision on bootHasUuids" fatal. Pick one; alias if you're mid-migration.make() has no ID. The creating event only fires on save. Post::factory()->make()->id is null — assert on IDs only after create(), or use factory()->create() in tests that need them.DB::table('posts')->insert(), upsert(), and insertOrIgnore() skip model events and will insert NULL keys. Same for Model::withoutEvents(...). If you use those deliberately, supply the UUID yourself.Str::orderedUuid(). This is the single biggest reason to prefer Laravel's native trait, which uses ordered UUIDs out of the box.uuid is char(36) — 36 bytes per key, replicated into every secondary index and every FK column. If a table is index-heavy, consider binary(16) with a cast; this package does not support that, so it's a manual change.foreignUuid(...)->constrained() will fail if the parent table hasn't been created yet, or if the parent uses $table->id() (bigint) — MySQL will error on type mismatch; SQLite may silently let it through and fail at query time.$model->getKeyName() / $model->getKey(). If another creating listener (e.g. a slug package, or an override in a child model) writes to the key first, the trait's empty() guard will skip generation and you'll get a partial/odd value. The guard only checks emptiness, so any earlier listener wins.id in $fillable. The trait fills it; allowing mass assignment of id lets a client choose its own key.$incrementing and $keyType must be set together. If you override one in your model, set both — a string key with $incrementing = true produces "1", "2", "3" style IDs that look like UUIDs to nobody and break sorting assumptions.whereKey() just finds nothing. If you need a 400, add a route-level whereUuid() constraint.orderBy('id') across the boundary — order by created_at.uuid as char(36) and are case-insensitive in comparisons; whereKey(strtoupper($id)) may match, which can mask normalization bugs. Store and compare lowercased strings consistently.How can I help you explore Laravel packages today?