- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your orders table has 20 million rows and a CHAR(36) primary key full of random UUIDv4 values. Writes were fine at 1 million rows. Now every insert lands in a random spot in the B-tree, InnoDB splits pages to make room, the buffer pool thrashes because the hot part of the index is scattered everywhere, and your p99 insert latency has doubled without a single code change.
You didn't do anything wrong. You picked UUIDs to keep ids opaque and to generate them in the app instead of the database. Good instincts. The version you picked is the problem. UUIDv4 is random by design, and a random primary key is the worst possible input for a clustered index.
Symfony's Uid component ships two identifiers that fix this: ULID and UUIDv7. Both are time-ordered. Both store in 16 bytes. Both hydrate into value objects your domain can own. Here's how to pick one and wire it correctly.
Why random ids punish your index
MySQL's InnoDB stores the table inside the primary key. The clustered index is the rows. When the key is monotonic (like auto-increment), every insert appends to the right edge of the tree. One hot page, sequential writes, tight cache.
When the key is random, inserts scatter. The engine has to find the right leaf page for each new row, and when that page is full it splits, leaving half-empty pages behind. The index bloats. The pages you need are spread across the whole tree, so the buffer pool can't keep the working set hot. This is well documented; Percona has written about UUIDv4 primary keys wrecking InnoDB write throughput for over a decade.
Postgres uses a heap, so the primary key isn't clustered the same way. But a random UUID still hurts: secondary index inserts scatter, and the pages you touch have no time locality, so the cache hit rate drops the same way.
The fix is not "go back to auto-increment." You lose app-side generation and you leak row counts. The fix is a time-ordered id: opaque like a UUID, but with the timestamp in the high bits so new rows sort to the end.
What ULID and UUIDv7 actually are
Both are 128 bits. Both put a 48-bit millisecond Unix timestamp in the leading bits, then fill the rest with randomness. That layout is the whole trick: sort two of them and the older one comes first, because the first six bytes are the clock.
The difference is packaging.
-
ULID is a 2016 spec. Its canonical form is a 26-character Crockford Base32 string like
01E439TP9XJZ9RPFH3T1PYBCR8. That string is lexicographically sortable, so it stays in time order even as text. -
UUIDv7 is an IETF standard. It was published in RFC 9562 in May 2024, which formalized the time-ordered layout and obsoleted the old RFC 4122. Its canonical form is the familiar hyphenated hex:
0190b8a4-.... Because the timestamp leads, that hex is time-ordered too.
Same physics, different label on the box. UUIDv7 is a real standard your Postgres uuid column, your other services, and your non-PHP consumers already understand. ULID gives you a shorter, URL-friendly string. That is the entire trade-off, and we'll come back to it.
Generating them in Symfony
Install the component:
composer require symfony/uid
Generating either one is a single call.
<?php
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
$ulid = new Ulid();
$uuid = Uuid::v7();
Both objects carry their timestamp, and you can read it back without a database round-trip. That is handy for debugging and for cheap "created around when?" checks.
<?php
$uuid = Uuid::v7();
$uuid->getDateTime(); // \DateTimeImmutable
$uuid->toRfc4122(); // "0190b8a4-6f1e-7c3d-..."
$uuid->toBinary(); // 16 raw bytes
$ulid = new Ulid();
$ulid->getDateTime(); // \DateTimeImmutable
$ulid->toBase32(); // "01E439TP9XJZ9RPFH3T1PYBCR8"
$ulid->toBinary(); // 16 raw bytes
The CLI helpers are worth knowing. php bin/console ulid:inspect <id> and uuid:inspect <id> print the timestamp, every encoding, and the RFC form. When someone pastes an id into a bug report, that's how you find out when it was created.
Store 16 bytes, not a 36-character string
This is the mistake that quietly costs you. A UUID stored as CHAR(36) is 36 bytes plus collation overhead per row, per index entry, per foreign key. The raw value is 16 bytes. On a table with a few indexes and millions of rows, the string form doubles your index size and halves how much fits in cache.
Symfony ships Doctrine types that store the binary form and hydrate back into value objects. Use them.
<?php
// src/Entity/Order.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity]
class Order
{
#[ORM\Id]
#[ORM\Column(type: UuidType::NAME, unique: true)]
private Uuid $id;
public function __construct(Uuid $id)
{
$this->id = $id;
}
public function id(): Uuid
{
return $this->id;
}
}
UuidType (and UlidType for ULIDs) declares the column as a fixed 16-byte binary and converts to and from the value object for you. Your PHP code works with a typed Uuid; the database stores the packed bytes. Swap UuidType for UlidType and the type-hint for Ulid and nothing else changes.
One thing to remember: when you filter by id in a custom query, tell Doctrine the parameter type so it packs to binary.
<?php
use Symfony\Bridge\Doctrine\Types\UuidType;
$qb->where('o.id = :id')
->setParameter('id', $order->id(), UuidType::NAME);
Miss that and you'll compare a binary column against a hex string, get zero rows, and burn an afternoon on it.
Generate the id in the domain, not the database
The reason to reach for these at all is that you can create the identity before the row exists. An auto-increment id doesn't exist until INSERT returns. A time-ordered UUID exists the moment your domain creates the aggregate, which means the object is valid and referenceable from birth.
Wrap it in a typed id so the rest of the code never touches a raw string.
<?php
// src/Domain/Order/OrderId.php
namespace App\Domain\Order;
use Symfony\Component\Uid\Uuid;
final class OrderId
{
private function __construct(
private readonly Uuid $value,
) {
}
public static function next(): self
{
return new self(Uuid::v7());
}
public static function fromString(string $id): self
{
return new self(Uuid::fromString($id));
}
public function toString(): string
{
return $this->value->toRfc4122();
}
public function equals(self $other): bool
{
return $this->value->equals($other->value);
}
}
Now a use case builds an Order with a real identity up front, publishes an OrderPlaced event that already carries the id, and hands the aggregate to a repository to persist. No flush() needed to learn what the order is called. The database is a place you save the object, not the authority that names it.
If you'd rather let Doctrine mint the id at insert time, the bridge ships UlidGenerator and UuidGenerator for the CUSTOM strategy. It works, but it pulls identity generation back toward the ORM. For a domain-first codebase, OrderId::next() in the use case is the cleaner seam.
ULID or UUIDv7: pick one and move on
They index the same. They store the same 16 bytes. So decide on packaging and interop.
Reach for UUIDv7 when the id crosses a boundary: a Postgres uuid column, another service, a mobile client, an analytics pipeline. It's the standard shape, every ecosystem parses it, and RFC 9562 means you're not betting on a single vendor's spec. For most backend systems this is the default.
Reach for ULID when the id shows up in URLs, logs, or anywhere a human reads it. The 26-character Base32 form is shorter than the 36-character UUID, has no hyphens to break on a double-click, and is case-insensitive. If your ids are user-facing strings, ULID reads better.
Either way, you keep the property that matters: new ids sort to the end of the index, writes stay sequential, and your orders table still inserts fast at 20 million rows. And because Symfony can convert between the forms ($ulid->toRfc4122() gives you a UUID-shaped string from a ULID), you're not locked in if you change your mind.
What's the primary-key type on your busiest table right now, and when did it last get profiled under real write load?
The choice of identifier is a small decision that leaks everywhere: your index layout, your event payloads, your public URLs. Keeping that concern behind a typed OrderId in the domain, instead of scattering raw UUID strings across controllers and queries, is exactly the kind of boundary that keeps an application legible as it grows. Decoupled PHP is about drawing those boundaries deliberately, so the parts the framework owns and the parts your domain owns stay separate long enough to outlive the framework you started on.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)