In Laravel 13, how do Eloquent's HasUuids and HasUlids traits change a model's primary key, and when is the key value assigned?
answer
- string key generated in PHP
- HasUuids uses Str::uuid7()
- ULID: 26 characters, lowercased
- no manual keyType or incrementing
- set during insert, before creating fires
basics
~20 sHasUuids and HasUlids make the primary key a PHP-generated string, a UUIDv7 or a lowercase ULID, and report the key as a non-incrementing string. The value is assigned when the model is inserted, so a new unsaved instance has no key.
solid answer
~40 sBoth traits swap the auto-increment integer for a string generated in PHP. In Laravel 13 `HasUuids` calls `Str::uuid7()`, a time-ordered 36-character UUID, and `HasUlids` stores a lowercase 26-character ULID; `HasVersion4Uuids` keeps the older ordered version-4 generator. Through `HasUniqueStringIds` they override `getKeyType()` to return `'string'` and `getIncrementing()` to return `false`, so you do not set `$keyType` or `$incrementing` yourself. The value is generated in `performInsert()`, before the `creating` event fires and only if the column is empty, so `new Booking` has a null key until `save()`, while `Booking::create()` returns with the key set. Override `newUniqueId()` to change the generator, or `uniqueIds()` to give a generated value to a column other than the primary key.
go deeper
Recall that adding HasUuids or HasUlids makes the model's key a generated string instead of an auto-increment integer.
Explain which getters the traits override, when setUniqueIds() runs relative to the creating event, and how uniqueIds() moves the ID to another column.
Choose between integer, UUIDv7 and ULID keys for index size, guessability and external consumers, and use a separate public ID column when joins should stay integer.
Weigh a platform-wide identifier standard: what other services, logs and URLs consume, and the cost of migrating keys once data exists.
## What the traits are By default an Eloquent model's primary key is an auto-incrementing integer `id` produced by the database. Two traits in `Illuminate\Database\Eloquent\Concerns` replace that with a **string identifier generated in PHP**: - `HasUuids` generates a **UUID**: a 36-character identifier such as `0199a4c2-...`. In Laravel 13 it calls `Str::uuid7()`, so the value is a **version 7** UUID whose leading bits encode time, which keeps new keys roughly ordered and friendlier to database indexes. - `HasUlids` generates a **ULID**: a 26-character, lexicographically sortable identifier. The trait lowercases it, so the stored key looks like `01gd4d3tgrrfqeda94gdbtdk5c`. - `HasVersion4Uuids` is a third trait that keeps the older ordered version-4 generator (`Str::orderedUuid()`) for apps that depend on it. The primary-key column must be able to hold the string, so the migration declares a UUID or ULID column as the key rather than an auto-increment one. ## What changes on the model Both traits share `HasUniqueStringIds`, which overrides two getters for any column listed in `uniqueIds()`: | Method | Default model | With HasUuids / HasUlids | |---|---|---| | `getKeyType()` | `'int'` | `'string'` | | `getIncrementing()` | `true` | `false` | | `uniqueIds()` | `[]` | `[primary key name]` | So, unlike a hand-mapped string key, you do **not** also set `$keyType = 'string'` and `$incrementing = false`; the trait answers for them. ## When the ID is assigned The identifier is generated at **insert time**, not when the object is built. The order inside `performInsert()` is: 1. `setUniqueIds()` fills each column in `uniqueIds()` that is still empty, using `newUniqueId()`; 2. the `creating` event fires, so observers already see the key; 3. timestamps are set; 4. the row is inserted with a plain `insert()`, because the model is not incrementing. Consequences worth stating in an interview: - `$booking = new Booking([...]);` has a `null` key until `save()`. - `Booking::create([...])` returns a model whose key is already set, with no extra query to fetch it. - A key you set yourself before saving is kept, because only empty columns are filled. - Because the key exists before the insert, you can attach it to related records or a queued message in the same unit of work without waiting for the database. ## Customising - Override `newUniqueId()` to change the generator, for example to a random version-4 UUID. - Override `uniqueIds()` to choose the columns. Returning `['public_ref']` keeps an integer `id` primary key and gives a separate column a generated value, which is a common pattern for exposing a non-guessable reference in URLs while joins stay on small integer keys. ```php use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Model; class Booking extends Model { use HasUlids; public function uniqueIds(): array { return ['public_ref']; } } ``` ## Choosing between them - **UUIDv7**: the widest interoperability, a native type in some databases, 36 characters as text. - **ULID**: shorter (26 characters), case-insensitive alphabet, sortable by creation time. - **Auto-increment**: smallest and fastest to index, but guessable and assigned only by the database. None is always right; teams usually choose by what other systems consume the ID and whether the key appears in public URLs. ## Pitfalls - **Query-builder inserts skip the trait.** `Booking::insert([...])` is passed straight to the query builder, so no model is built, `newUniqueId()` never runs and the key column receives nothing. `upsert()` on an Eloquent builder does add generated IDs to rows that lack them. - **Foreign keys must match.** Tables that reference a UUID or ULID key need string-compatible foreign-key columns, not unsigned integers. - **Keys are strings in PHP.** Comparisons such as `$booking->id === 5` never match, and a key received from outside should be validated as a UUID or ULID before it is used in a lookup. - **Adding the trait to an existing integer-keyed model** changes the key type Eloquent reports but does not convert existing rows; the schema and data have to be migrated as well.
- How would you keep an integer primary key but expose a ULID in public URLs with Eloquent?Use `HasUlids` and override `uniqueIds()` to return `['public_ref']`. The trait then fills `public_ref` on insert, while `id` stays an auto-incrementing integer because it is no longer in `uniqueIds()`. Joins and foreign keys keep using the compact integer, and URLs use the non-guessable ULID column.
- Does `HasUuids` overwrite a UUID you assign before saving?No. `setUniqueIds()` only fills a unique-id column when it is empty, so a key you set explicitly, for example one received from another system, is inserted as given.
saying these in an interview costs you the question
- HasUuids needs $keyType = 'string' and $incrementing = false added by hand
- The UUID is generated as soon as the model is instantiated
- HasUlids produces 36-character dashed identifiers
- The database generates the UUID through a column default
- HasUuids in Laravel 13 generates random version-4 UUIDs