DEV Community

Cover image for Laravel UUID and ULID Keys: What They Are and Why Use Them
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel UUID and ULID Keys: What They Are and Why Use Them

Open your app and look at the address bar. If it says /orders/1042, anyone can try /orders/1043. They can also guess that you have roughly a thousand orders. That is the quiet downside of the auto-incrementing ID every Laravel migration gives you by default. Laravel has a built-in alternative that most people never switch on: UUID and ULID keys. It takes about two lines of code, and I tested it on a real database so you can see what it costs.

What they look like

Type Example Length
Auto-increment 1042 a number
UUID 01a14d67-ea88-71fc-893f-1145c5da4047 36 characters
ULID 01m56pftys1d0n9dfadp89frzx 26 characters

Both are practically impossible to guess and can be generated anywhere without asking the database for the next number. The Laravel documentation describes UUIDs as universally unique alphanumeric identifiers 36 characters long, and ULIDs as similar but only 26 characters. By default, Laravel generates UUIDv7, which starts with a timestamp so newer IDs sort after older ones. The docs say this makes them more efficient for indexed database storage. ULIDs are sortable in the same way.

The simple example

Three small changes. First, the migration uses a UUID column as the primary key instead of id():

Schema::create('articles', function (Blueprint $table) {
    $table->uuid('id')->primary();
    $table->string('title');
    $table->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

Second, add the trait to the model:

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    use HasUuids;

    protected $fillable = ['title'];
}
Enter fullscreen mode Exit fullscreen mode

That is it. The trait fills in the ID when you create a record, and tells Eloquent the key is a string and not an incrementing number:

$article = Article::create(['title' => 'Traveling to Europe']);

$article->id;   // "01a14d67-ea88-71fc-893f-1145c5da4047"
Enter fullscreen mode Exit fullscreen mode

Route model binding keeps working with no changes: Route::get('/articles/{article}', ...) now produces URLs like /articles/01a14d67-ea88-71fc-893f-1145c5da4047. Prefer ULIDs? Swap in HasUlids and use $table->ulid('id')->primary() in the migration. Everything else is identical.

What I saw when I ran this against the real framework: five records created a few milliseconds apart got IDs that sorted in creation order, with plain string sorting and in an ORDER BY id query. getKeyType() returned string and getIncrementing() returned false. Laravel's MariaDB grammar created the column as uuid for $table->uuid() and char(26) for $table->ulid(). Foreign keys have a helper too: $table->foreignUuid('article_id')->constrained() created a uuid column with the foreign key constraint.

Why bother? Three real reasons

1. Public URLs nobody can enumerate

With numeric IDs, scraping every record in your app is a loop from 1 to 100,000. With UUIDs there is nothing to count up. It also stops outsiders from reading your size off the URL (an order number of 1042 tells everyone you are small). One important warning: an unguessable ID is not access control. If someone gets a link, they can use it. Always keep your authorization checks (policies, ownership checks). UUIDs only remove the easy guessing.

2. You know the ID before you save

The ID is created in your PHP code, not by the database. You can build a record, attach related records, queue a job or return a response with the ID, all before anything is written. It also means two systems, or an offline mobile app, can create records independently and merge them later without ID collisions.

3. Invalid IDs are rejected cheaply

I tested route binding with a value that is not a UUID. Laravel threw ModelNotFoundException (a 404 for the user) without running a single database query. A valid ID ran one query, as usual. Junk requests never reach your database.

What it costs

Nothing is free. UUIDs are bigger than a number, and the database stores your rows in primary key order, so the kind of ID matters. I inserted 500,000 rows into four otherwise identical tables on MariaDB 10.11 (1,000 rows per insert statement):

Primary key Insert time Table size
Auto-increment number 3.5 s 49.6 MB
UUIDv7 (Laravel's default) 4.2 s 53.6 MB
ULID 4.7 s 59.7 MB
Random UUIDv4 5.9 s 78.6 MB

The pattern is clear. Time-ordered IDs (UUIDv7 and ULID) land at the end of the index, like numbers do, so they cost only a little. Fully random UUIDv4 scatter across the index, which makes inserts slower and the table about 58% bigger than the numeric one in this test. That is why Laravel's default is the ordered kind. Single-row lookups by ID were about the same for all four. Two caveats: these were single runs on a small container, so treat them as relative, and larger IDs are also copied into every other index and foreign key that points at them, which I did not measure.

The best of both: keep the number, add a public ID

You do not have to change your primary key. A popular pattern is to keep the fast auto-increment id for the database and add a separate UUID column that is the only thing you ever show in URLs. The documentation shows how to choose which columns get unique IDs with a uniqueIds() method:

class Order extends Model
{
    use HasUuids;

    public function uniqueIds(): array
    {
        return ['public_id'];
    }

    public function getRouteKeyName(): string
    {
        return 'public_id';
    }
}
Enter fullscreen mode Exit fullscreen mode

With a migration that has $table->id() and $table->uuid('public_id')->unique(), a new order got id = 1 and a generated public_id. The key type stayed an incrementing integer, and route binding looked the order up with where public_id = ?. Passing 1 to the route was rejected as not found, so the number never works in a URL. You get small, fast joins inside the database and unguessable links outside.

Mistakes to avoid

  • Forgetting the column type. The model and the migration must agree. The docs say to make sure the table has a UUID-equivalent primary key column. A normal id() column will not hold a UUID.
  • Using random UUIDv4 as a primary key on a big table. The docs show how to override newUniqueId() to return a v4 UUID. It works, but see the table above. Prefer the default ordered kind for primary keys.
  • Treating the ID as a secret. Keep your authorization checks.
  • Converting an existing table casually. Every foreign key that points at it needs the same change, and old links will break. Plan it as a proper migration, or use the public ID pattern above, which needs only one new column.

Frequently asked questions

UUID or ULID?

Both work well in Laravel. UUIDs are the more widely recognised standard and are understood by many tools and database types. ULIDs are shorter (26 characters), which is nicer in URLs. In my test the UUIDv7 table was a little smaller and faster than the ULID one, but only because the database stored the UUID in a compact native column type, so check how your own database stores each.

Do UUID keys work with relationships and factories?

Yes. Relationships just need matching column types, and foreignUuid() creates them in migrations.

Should every new app use UUIDs?

No. For internal tables that users never see, numbers are smaller and simpler. Use UUIDs or ULIDs where IDs appear in URLs or APIs, or where records are created in more than one place.

References


Originally published on DEV Talk.

Top comments (0)