DEV Community

Cover image for ULID and UUID v7 in PHP: Modern IDs for Domain Entities
Gabriel Anhaia
Gabriel Anhaia

Posted on

ULID and UUID v7 in PHP: Modern IDs for Domain Entities


You write INSERT INTO orders (...) VALUES (...) and Postgres hands you back RETURNING id. The handler grabs the integer, sticks it on the response, you ship the JSON. It works. It has worked for fifteen years.

Then the team adds a queue worker that creates the order in one service and reads it from another a few milliseconds later. The producer needs to publish the event with an ID. The consumer needs to find the row by that ID. Neither can wait for a database round-trip to learn what the ID is. So the team starts threading the auto-increment value through three systems, or worse, builds a synchronous "create then fan-out" flow that pretends the database is the source of truth for ordering.

The fix is older than the problem. Pick an ID scheme the application owns, not the database. ULID and UUID v7 are the two formats that earn that job in PHP today. Both are 128-bit and time-sortable. You can generate them anywhere and drop them straight into a BINARY(16) column or a Postgres uuid. This post walks the working code for both, wraps them as value objects, and shows where each one falls down.

Why auto-increment hurts past a single service

The auto-increment column is fine when one process writes one table. It breaks in three predictable ways:

  1. You can't know the ID until after the insert. Outbox patterns, idempotent retries, and event publishing all want to construct the entity, give it identity, and then persist. A round-trip to learn the ID inverts that order.
  2. The ID leaks ordering and rate. A competitor scraping /orders/41832 then /orders/41835 three seconds later learns your throughput. The BIGSERIAL is a public counter.
  3. Sharding and merging fall over. Two databases can't safely both own the same sequence. The moment you split a single table across regions, the integer key becomes the worst data-migration job you'll ever do.

UUID v4 fixes points 1 and 2 (it's random, 128 bits, generated anywhere) but breaks the index. B-tree inserts on random keys fragment the leaf pages, and once the working set leaves RAM the write amplification gets ugly. The UUID v7 RFC calls this out directly: random UUIDs hurt insert locality once the table is bigger than the buffer pool, which is the whole reason v7 puts the timestamp in the high bits.

ULID and UUID v7 keep the "generate anywhere" win and put the timestamp in the high bits, so newly inserted rows append to the index instead of scattering across it. You get the application-side identity model without the index regression.

Auto-increment vs UUID v4 vs UUID v7 index growth

The two formats, side by side

Both are 128 bits. Both put a millisecond timestamp in the leading 48 bits. The rest is random.

ULID:     01HQZK9VBM7N3X5T8R2Q4Y6W8E
          ^^^^^^^^^^^^^^^^^^^^^^^^^^
          ^^^^^^^^^^ 48-bit time (10 base32 chars)
                    ^^^^^^^^^^^^^^^^ 80-bit random (16 base32 chars)
          (Crockford base32, 26 chars total)

UUID v7:  01951a3c-7b00-7a8c-9f4e-3d2b1c0a8f7e
          ^^^^^^^^^^^^^
          48-bit time (first 12 hex chars)
                        ^^^^^^^^^^^^^^^^^^^^^^^
                        74 random + 6 version/variant bits
          (hex with dashes, 36 chars total)
Enter fullscreen mode Exit fullscreen mode

What's actually different:

  • Wire format. ULID's Crockford base32 is shorter (26 chars vs 36) and case-insensitive without ambiguity (no I, L, O, U). UUID v7 looks like every other UUID, so it pastes cleanly into anything that already speaks UUID.
  • Database support. Postgres has a native uuid type. MySQL has BINARY(16). Both store 16 bytes either way. For UUID v7 in Postgres 8.2+ the type is built in; ULID needs an extension or a bytea/char(26) decision.
  • Standards. UUID v7 is in RFC 9562 (May 2024, finalized after years as a draft). ULID has a spec on GitHub but no IETF RFC.
  • Library reach. Every language has a UUID library; many have v7 now. ULID has libraries everywhere too, but adoption is thinner outside the JS/Node and PHP ecosystems.

For a greenfield PHP service in 2026, UUID v7 is the safer pick: it's RFC-backed, has a native Postgres type, and is the most widely tooled. ULID is the better pick when the ID will be copy-pasted by humans (support tickets, URLs, log greps) or when you want the shorter string form.

The rest of this post uses UUID v7 in the code samples and notes the ULID swap at the end.

Generating UUID v7 with ramsey/uuid

The ramsey/uuid library added native UUID v7 support in version 4.7. Install it once:

composer require ramsey/uuid:^4.7
Enter fullscreen mode Exit fullscreen mode

Generate one:

<?php

declare(strict_types=1);

use Ramsey\Uuid\Uuid;

$id = Uuid::uuid7();
echo $id->toString();
// 01951a3c-7b00-7a8c-9f4e-3d2b1c0a8f7e
Enter fullscreen mode Exit fullscreen mode

That's the whole API. The library reads the current millisecond, packs it into the leading 48 bits, sets the version (7) and variant bits, and fills the rest with cryptographic random bytes from random_bytes(). No external service, no DB round-trip, no clock-skew committee.

You can pass a DateTimeInterface if you need a backdated ID for migrations or tests:

$id = Uuid::uuid7(new DateTimeImmutable('2026-01-01T00:00:00Z'));
Enter fullscreen mode Exit fullscreen mode

Wrapping the ID as a value object

A raw Uuid object is fine for one-off scripts. In a service with a domain layer, you want every domain entity to have its own ID type (OrderId, CustomerId, InvoiceId) so the type system catches a swap before it reaches production.

The pattern is a thin readonly class around the string form, with three named constructors:

<?php

declare(strict_types=1);

namespace App\Order\Domain;

use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;

final readonly class OrderId
{
    private function __construct(public string $value) {}

    public static function generate(): self
    {
        return new self(Uuid::uuid7()->toString());
    }

    public static function fromString(string $value): self
    {
        $uuid = Uuid::fromString($value);
        if ($uuid->getFields()->getVersion() !== 7) {
            throw new \InvalidArgumentException(
                "OrderId must be UUID v7, got version "
                . $uuid->getFields()->getVersion()
            );
        }
        return new self($uuid->toString());
    }

    public static function fromBytes(string $bytes): self
    {
        return new self(Uuid::fromBytes($bytes)->toString());
    }

    public function toBytes(): string
    {
        return Uuid::fromString($this->value)->getBytes();
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things to notice.

  • generate() is a static factory, not a constructor. The constructor is private. Code reading this class never wonders whether new OrderId() creates a new ID or wraps an existing one. There is no new OrderId().
  • fromString() validates the version. A v4 UUID passed in by mistake will throw at the boundary, not after it has corrupted three downstream tables.
  • toBytes() exists for the database. Storing UUIDs as text wastes 20 bytes per row and slows the index. The binary form is what the persistence adapter wants.

The entity now owns its identity from the moment it is constructed:

final readonly class Order
{
    public function __construct(
        public OrderId $id,
        public CustomerId $customerId,
        public Money $total,
        public DateTimeImmutable $createdAt,
    ) {}

    public static function place(
        CustomerId $customer,
        Money $total,
        DateTimeImmutable $now,
    ): self {
        return new self(
            id: OrderId::generate(),
            customerId: $customer,
            total: $total,
            createdAt: $now,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Order::place() returns a fully-formed order with a permanent ID, before any database has been touched. The handler can publish the OrderPlaced event with order.id and trust the consumer will find the row when persistence finishes.

Persisting to Postgres

Postgres has a native uuid column type that stores 16 bytes and accepts the canonical hex-with-dashes form on input:

CREATE TABLE orders (
    id           uuid PRIMARY KEY,
    customer_id  uuid NOT NULL,
    total_cents  bigint NOT NULL,
    currency     char(3) NOT NULL,
    created_at   timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

The adapter is plain PDO. No ORM annotations, no custom Doctrine type. Bind the string form:

<?php

declare(strict_types=1);

namespace App\Order\Adapter\Persistence;

use App\Order\Domain\Order;
use App\Order\Domain\OrderRepository;
use PDO;

final readonly class PostgresOrderRepository implements OrderRepository
{
    public function __construct(private PDO $pdo) {}

    public function save(Order $order): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO orders
             (id, customer_id, total_cents, currency, created_at)
             VALUES (:id, :customer_id, :total, :currency, :created_at)'
        );
        $stmt->execute([
            'id'          => $order->id->value,
            'customer_id' => $order->customerId->value,
            'total'       => $order->total->cents,
            'currency'    => $order->total->currency,
            'created_at'  => $order->createdAt->format('c'),
        ]);
    }

    public function find(OrderId $id): ?Order
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, customer_id, total_cents, currency, created_at
             FROM orders WHERE id = :id'
        );
        $stmt->execute(['id' => $id->value]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            return null;
        }
        return new Order(
            id:         OrderId::fromString($row['id']),
            customerId: CustomerId::fromString($row['customer_id']),
            total:      Money::of((int) $row['total_cents'], $row['currency']),
            createdAt:  new \DateTimeImmutable($row['created_at']),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two production notes.

  • MySQL. No native uuid type. Use BINARY(16) and bind $order->id->toBytes() instead of the string form. The 16-byte form sorts identically to the string form because the timestamp is at the top.
  • Doctrine. If you must use it, ramsey/uuid-doctrine provides a uuid and uuid_binary type. Map the entity's OrderId through a custom type so the conversion stays at the persistence edge, not in the domain.

UUID v7 layout and Postgres uuid column mapping

Time-sorted, by design

Because the timestamp is the high-order 48 bits, an ORDER BY id returns rows in roughly creation order:

SELECT id, total_cents, created_at
FROM orders
WHERE customer_id = '01951a3c-7b00-7a8c-9f4e-3d2b1c0a8f7e'
ORDER BY id DESC
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode

That query uses the primary-key index directly. No extra index on created_at. No secondary sort. The "newest first" pagination most apps need is the natural sort of the key.

Two caveats worth knowing:

  • Same-millisecond ordering is random. Two IDs generated in the same millisecond on the same machine will not preserve creation order. If you need strict creation ordering inside a millisecond, add a sequence column or use the spec's monotonic counter variant (ULID supports this; UUID v7 has optional method 1 monotonicity that ramsey/uuid does not enable by default).
  • Clock skew across hosts. If two servers disagree on the time by 200 ms, IDs from the lagging server will sort behind newer IDs from the fast one. NTP usually keeps this under 10 ms, which is fine for "newest first" UX but not for anything that needs strict global ordering.

For the latter, a database sequence is still the right answer. ULID and UUID v7 give you "good enough for the UI" sort, not a Lamport clock.

Swapping in ULID

If you decide ULID is a better fit (shorter, more human-friendly), the value object barely changes. With symfony/uid, which ships ULID support:

use Symfony\Component\Uid\Ulid;

final readonly class OrderId
{
    private function __construct(public string $value) {}

    public static function generate(): self
    {
        return new self((new Ulid())->toBase32());
    }

    public static function fromString(string $value): self
    {
        if (!Ulid::isValid($value)) {
            throw new \InvalidArgumentException("Invalid ULID: $value");
        }
        return new self($value);
    }

    public function toBytes(): string
    {
        return Ulid::fromString($this->value)->toBinary();
    }
}
Enter fullscreen mode Exit fullscreen mode

Storage in Postgres becomes bytea or char(26). In MySQL, still BINARY(16). The domain code that calls OrderId::generate() does not change. That is the payoff of the value-object wrapper: the entity does not know which format won the meeting.

When not to do this

A few cases where the auto-increment integer is still the right pick:

  • A single-process internal tool with one writer and no events.
  • Tables you never expose over an API and never replicate.
  • Bulk import staging tables you'll drop next week.

For anything else, generate the ID in the application and wrap it as a value object. Outbox, event bus, second region, public URL, forecasted shard: any of those, and the database stops being the owner of identity. It becomes one of the things that store it.


If this was useful

This is the slice of Decoupled PHP where identity, persistence, and the dependency rule meet. The book takes the same value-object shape through the rest of the domain (Money, EmailAddress, OrderStatus) and shows how the persistence adapter stays a 30-line PDO class instead of an ORM annotation hairball. Database Playbook is the companion when the question is which store deserves the table in the first place.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)