The column you can't change without a war
Every table starts with a primary key, and the choice usually gets made by whatever the framework scaffolded. That's fine, because the key itself is cheap. It's cheap until you need to change it: altering a primary key means rewriting the table, every foreign key that points at it, every join index, and a migration plan for a live system. So the question is worth ten minutes of actual thought before the first migration runs.
What the boring default already buys you
Plain auto-increment integers, the default in MySQL and Postgres, have real virtues. They're small: 4 or 8 bytes in the key, in every foreign key, in every join. Inserts always land at the right edge of the index, so pages stay hot and filling stays efficient. Reading order 48152 in a support ticket is easier than reading a 26-character identifier. For an internal admin tool, that's genuinely the whole story.
What those small integers leak
The same sequence is also public information about your business. An endpoint like /invoices/12844 tells anyone who samples it monthly exactly how fast you're growing. Sequential IDs also make enumeration trivial, which means authorization checks stop being defense-in-depth and become the only thing between a stranger and another customer's records.
And when two systems need to merge, both of which numbered their orders 1 through 50000, every ID collides and the merge becomes a remapping project.
What UUIDs fix, and what they cost
UUIDs let any process mint a key with no coordination: the mobile app offline, the queue worker, the second server behind the load balancer. They're safe in URLs, safe to expose, and merge cleanly.
The cost is honest too. At 16 bytes they bulk up the table, the primary key index, and every foreign key column. Random UUIDv4 inserts land all over the index, splattering writes across pages, which measurably hurts on large tables.
UUIDv7 fixes the ordering problem: timestamp-prefixed, so new IDs sort after old ones and index inserts stay append-ish. Laravel 11 and newer generate v7 by default with HasUuids. ULIDs (26 characters, also time-ordered) via HasUlids are the shorter, more copyable alternative.
The shape we ship most often
For products with a public API or customer-facing URLs, the hybrid beats both purist options:
$table->id(); // internal: fast, small
$table->uuid('public_id')->unique(); // external: exposed in URLs
Routes bind on public_id, the integer never leaves the server, and the auth layer no longer leans on IDs being unguessable. One extra column and one index, and you get the insert performance of integers with the embeddability of UUIDs. Stripe's identifiers (cus_, ch_) follow the same idea.
When a single column is the right call
- Purely internal tools, no shared URLs, no integrations: auto-increment, no ceremony.
- Public SaaS, mobile clients, third-party integrations: UUIDv7 as the primary key, accepting the storage cost.
- Anything customer-facing where you're not sure: hybrid. It's the choice that survives the requirements changing.
The expensive part is switching later
Teams rarely regret having a public identifier. They regularly regret not having one, because adding it later means backfilling a live table, updating every URL someone has bookmarked, and keeping both identifiers dual-served through a deprecation window. Ten minutes on day one buys you out of that whole project.