Skip to content

UUID Generations

Precious Lyson edited this page Nov 13, 2026 · 2 revisions

This component library provides four related tools for generating unique and/or short-lived identifiers:

Tool Purpose Typical Use
UUID (Uuid, UuidGenerator) 128-bit globally unique identifiers (RFC 4122) Database primary keys, distributed IDs, resource identifiers
ULID (UlidComponent) 128-bit lexicographically sortable identifiers Time-ordered primary keys, log correlation, cursor pagination
OTP (OtpGenerator, OtpCacheManager) Short numeric/alphanumeric one-time passwords Email/SMS verification, passwordless login, transaction confirmation
TOTP (OtpGenerator::getTotp) Time-based one-time passwords (RFC 6238) Google Authenticator–style 2FA

All classes are cryptographically secure (using random_bytes / random_int) and designed for modern PHP 8+.


If you use OtpCacheManager, you must also provide a CacheManager implementing get, set, and delete (usually resolved via your container's resolve() helper).


Component Overview

UUID/
├── Random/
│   ├── Uuid.php              # Immutable UUID value object
│   └── UuidGenerator.php     # Factory for v1, v3, v4
├── ULID/
│   └── UlidComponent.php     # ULID generation, parsing, validation
└── OTP/
    ├── OtpGenerator.php      # OTP & TOTP generation/verification
    └── OtpCacheManager.php   # OTP storage via CacheManager

Beginner Guide

Generating a Random UUID (v4)

The simplest and most common UUID:

use Mlangeni\Machinjiri\Core\Components\UUID\Random\UuidGenerator;

$uuid = UuidGenerator::v4();
echo $uuid; // e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"

$uuid is a Uuid object, and __toString() returns the canonical 8-4-4-4-12 string. You can store it directly in a VARCHAR(36) column.

Generating a ULID

ULIDs are shorter (26 chars), URL-safe, and sortable by creation time:

use Mlangeni\Machinjiri\Core\Components\UUID\ULID\UlidComponent;

$ulid = UlidComponent::generate();
echo $ulid; // e.g. "01HZX9K3F5J8M2N4P6Q8R0S2T4"

Because ULIDs are lexicographically sortable, you can use them as PRIMARY KEY values and ORDER BY id will sort by insertion time.

Generating a Simple Numeric OTP

use Mlangeni\Machinjiri\Core\Components\UUID\OTP\OtpGenerator;

$otp = OtpGenerator::numeric(6);
echo $otp; // e.g. "482913"

⚠️ This only generates a string. To persist and verify it later, see Storing & Verifying OTPs.


Intermediate Guide

UUID Versions Explained

Version Basis Deterministic? When to use
v1 Timestamp + clock sequence + node No (but time-ordered) You need sortable IDs without ULID; legacy systems
v3 MD5(namespace + name) Yes Stable IDs for known names (e.g. same email → same UUID)
v4 Pure random (122 bits) No Default choice; maximum uniqueness
use Mlangeni\Machinjiri\Core\Components\UUID\Random\UuidGenerator;

// Version 1 (time-based)
$v1 = UuidGenerator::v1();

// Version 3 (name-based, MD5) — namespace can be a UUID string or object
$v3 = UuidGenerator::v3(
    '6ba7b810-9dad-11d1-80b4-00c04fd430c8', // DNS namespace
    'example.com'
);

// Version 4 (random)
$v4 = UuidGenerator::v4();

// Or use the generic factory:
$uuid = UuidGenerator::generate(4);
$uuid = UuidGenerator::generate(3, $namespace, 'name');

Working with UUID Objects

Uuid is an immutable value object. Key methods:

use Mlangeni\Machinjiri\Core\Components\UUID\Random\Uuid;

$uuid = UuidGenerator::v4();

echo $uuid->toString();          // "f47ac10b-58cc-4372-a567-0e02b2c3d479"
echo $uuid->getVersion();        // 4
echo $uuid->getVariant();        // 1 (RFC 4122)
$binary = $uuid->getBytes();     // 16 raw bytes (for BINARY(16) columns)

// Parse a string back to an object:
$parsed = Uuid::fromString('f47ac10b-58cc-4372-a567-0e02b2c3d479');

// From raw binary:
$rebuilt = Uuid::fromBytes($binary);

Storing UUIDs as BINARY(16) roughly halves storage vs. VARCHAR(36):

$stmt->bindValue(':id', $uuid->getBytes(), PDO::PARAM_LOB);

Parsing & Validating ULIDs

use Mlangeni\Machinjiri\Core\Components\UUID\ULID\UlidComponent;

$ulid = UlidComponent::generate();

// Validate
if (UlidComponent::validate($ulid)) {
    echo "Valid ULID\n";
}

// Parse into components
$parts = UlidComponent::parse($ulid);
echo $parts['timestamp'];          // milliseconds since epoch
echo $parts['randomnessHigh'];     // 40-bit int
echo $parts['randomnessLow'];      // 40-bit int
echo $parts['timestampDateTime'];  // DateTimeHandler object

// Extract just the timestamp
$ms = UlidComponent::extractTimestampMs($ulid);
$dt = UlidComponent::extractTimestampDateTime($ulid);

This lets you implement cursor pagination using ULIDs:

$lastUlid = $request->get('cursor');
$nextPage = $db->query(
    "SELECT * FROM posts WHERE id > :cursor ORDER BY id ASC LIMIT 20",
    ['cursor' => $lastUlid]
);

Monotonic ULIDs

By default, two ULIDs generated in the same millisecond are randomly ordered. Monotonic ULIDs guarantee increasing order even within the same millisecond:

$ulid1 = UlidComponent::generateMonotonic();
$ulid2 = UlidComponent::generateMonotonic();
// $ulid2 > $ulid1 always (string comparison)

This is essential for primary keys and audit logs where tie-breaking must be deterministic.

OTP Types & Customization

OtpGenerator supports four types:

use Mlangeni\Machinjiri\Core\Components\UUID\OTP\OtpGenerator;

OtpGenerator::numeric(6);                         // "042781" (digits only)
OtpGenerator::alphanumeric(8);                    // "aX9k2Pq1"
OtpGenerator::alphabetic(6);                      // "KdTqMb"
OtpGenerator::characterBased(5, 'ABCDEF');        // "CABFE" (custom set)

Generate an OTP with an expiry timestamp in one call:

$result = OtpGenerator::generateWithExpiry(
    length: 6,
    type: 'numeric',
    ttlSeconds: 300
);
// $result = ['otp' => '482913', 'expires_at' => 1735689600]

// Later:
$isValid = OtpGenerator::verify($userInput, $result['otp'], $result['expires_at']);

Storing & Verifying OTPs with Cache

For real applications, you need to store the OTP so you can verify it later (a plain OtpGenerator::verify() on its own doesn't persist anything). OtpCacheManager does this for you:

use Mlangeni\Machinjiri\Core\Components\UUID\OTP\OtpCacheManager;
use Mlangeni\Machinjiri\Core\Components\UUID\OTP\OtpGenerator;

// The constructor will resolve CacheManager from the container,
// or you can inject one explicitly.
$otpManager = new OtpCacheManager();

// 1. Generate + store
$otp = $otpManager->generateAndStore(
    identifier: 'user@example.com',
    length: 6,
    type: 'numeric',
    ttlSeconds: 300
);

// Send $otp to the user (email, SMS, etc.) — NOT back in the HTTP response.

// 2. Later, verify user input
if ($otpManager->verify('user@example.com', $userInput)) {
    // Success — but don't forget to invalidate!
    $otpManager->delete('user@example.com');
    // Proceed with login / passwordless flow
} else {
    // Invalid or expired
}

// 3. Optionally, check remaining lifetime
$secondsLeft = $otpManager->getRemainingTtl('user@example.com');

🔒 Always delete() after successful verification. Otherwise an OTP can be replayed until it expires.


Advanced Guide

UUID Version 1 (Time-based) Internals

UuidGenerator::v1() builds a 60-bit timestamp offset from the Gregorian epoch (1582-10-15) in 100-nanosecond intervals, a random 14-bit clock sequence, and a random 48-bit node with the multicast bit set.

Key detail from the implementation:

$microtime = microtime(true);
$intervals = ($seconds * 10000000) + ($usec * 10) + $gregorianOffset;

The tie-breaker — the clock sequence — is regenerated each call, so two v1 UUIDs generated in the same nanosecond may collide only with probability ≈ 1/16384. For heavy generation (thousands per second), consider a monotonic clock sequence (not provided here) or use v4/ULID.

UUID Version 3 (Name-based, MD5)

V3 is deterministic:

$ns  = Uuid::fromString('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // DNS
$a = UuidGenerator::v3($ns, 'user@example.com');
$b = UuidGenerator::v3($ns, 'user@example.com');
// $a->toString() === $b->toString()  → true

This is useful for idempotent resource creation: derive the ID from stable input rather than storing a lookup table.

⚠️ MD5 is cryptographically weak. Do not use V3 for security-sensitive uniqueness. For secure name-based IDs, use a v5-style implementation with SHA-1 (not included here) or derive a hash yourself.

ULID Bit Layout & Encoding

A ULID is 128 bits encoded in Crockford's Base32 (no I, L, O, U):

 48 bits                   80 bits
┌──────────────────┬──────────────────────────────────────┐
│  timestamp (ms)  │             randomness               │
└──────────────────┴──────────────────────────────────────┘
 ↑                   ↑
 10 base32 chars     16 base32 chars

The randomness is split into two 40-bit halves (randomnessHigh, randomnessLow) to avoid integer overflow on 32-bit systems and to simplify encoding.

Encoding (from the source):

$randHighParts = self::intToBase32($randHigh, 8); // 40 bits → 8 chars
$randLowParts  = self::intToBase32($randLow,  8); // 40 bits → 8 chars

Because Base32 works in 5-bit groups and 40 ÷ 5 = 8, each half encodes cleanly with no padding. This is a portable and allocation-friendly design.

TOTP (RFC 6238) — 2FA Ready

OtpGenerator includes a full TOTP implementation compatible with Google Authenticator, Authy, 1Password, etc.

Provisioning a new 2FA secret:

use Mlangeni\Machinjiri\Core\Components\UUID\OTP\OtpGenerator;

// 1. Generate a Base32 secret (20 bytes = 160 bits, RFC-recommended)
$secret = OtpGenerator::generateTotpSecret(20);
// → e.g. "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"

// 2. Show a QR code to the user with this URI:
$label   = rawurlencode('alice@example.com');
$issuer  = rawurlencode('MyApp');
$uri = "otpauth://totp/{$issuer}:{$label}?secret={$secret}&issuer={$issuer}";
// Render $uri as a QR code (use endroid/qr-code or similar)

// 3. Store $secret (encrypted!) on the user record

Verifying at login:

$userCode = $_POST['totp'];          // 6-digit code from Authenticator
$secret   = $user->totp_secret;      // Base32

if (OtpGenerator::verifyTotp($userCode, $secret)) {
    // ✓ Valid (allows ±1 time-window drift = ~±30s)
} else {
    // ✗ Invalid or expired
}

Direct computation if you need to display the current code (e.g., for tests):

$current = OtpGenerator::getTotp($secret, 30, 6); // 6-digit, 30s window

Configuration options:

Parameter Default Notes
$timeWindow 30 s Must match client (Authenticator uses 30)
$digits 6 6 or 8 supported
$drift 1 Checks windows t-1, t, t+1
Secret length 20 bytes (160-bit) 10–64 bytes allowed

🔐 Store TOTP secrets encrypted at rest. A leaked secret allows unlimited code generation.

Security Considerations

UUIDs & ULIDs

  • ULIDs leak creation time in the first 10 characters. Fine for internal IDs; if you expose them publicly, consider truncating or hashing.
  • UUID v1 also leaks time (and node/MAC if not randomized; this implementation randomizes the node).
  • V4 UUIDs are 122 bits of entropy — collision probability is astronomically low (≈ 1 in 2^61 for a billion UUIDs).
  • When storing UUIDs in SQL, prefer BINARY(16) ($uuid->getBytes()). This also avoids case-sensitivity bugs (UUID() vs uuid()).

OTPs

  • Rate-limit attempts. A 6-digit OTP has only 1,000,000 combinations; without throttling it's brute-forceable. Track failed attempts and lock after N tries.
  • Use short TTLs (60–300 seconds) and always delete() after success.
  • Send OTPs out-of-band (email, SMS) — never return them in the HTTP response body or logs.
  • OtpCacheManager::verify() uses hash_equals (constant-time) to prevent timing attacks. ✅
  • The identifier is sanitized before being used as a cache key — this is important because email addresses contain @ and ., which some cache backends reject.
  • Avoid the same OTP for multiple users — the identifier parameter must be unique per recipient.

TOTP

  • Never log or transmit the secret.
  • Consider one-time backup codes for users who lose their authenticator.
  • Reject reused codes: RFC 6238 recommends that the server remember the last used timestamp per user (not implemented here — add it in your auth layer if strict replay prevention is needed).

Extending & Integrating

Custom OTP storage (e.g., Redis, database):

class RedisOtpStore
{
    public function __construct(private \Redis $redis) {}

    public function generateAndStore(
        string $identifier, int $length, string $type = 'numeric',
        int $ttlSeconds = 300, ?string $customChars = null
    ): string {
        $otp = match ($type) {
            'numeric'      => OtpGenerator::numeric($length),
            'alphanumeric' => OtpGenerator::alphanumeric($length),
            'alphabetic'   => OtpGenerator::alphabetic($length),
            'characterBased' => OtpGenerator::characterBased($length, $customChars),
        };
        $this->redis->setex("otp:{$identifier}", $ttlSeconds, $otp);
        return $otp;
    }

    public function verify(string $identifier, string $otp): bool
    {
        $stored = $this->redis->get("otp:{$identifier}");
        return $stored !== null && hash_equals($stored, $otp);
    }
}

Dependency injection:

OtpCacheManager accepts an optional CacheManager. In a framework like Laravel or Symfony, bind it explicitly:

$otpManager = new OtpCacheManager(app(CacheManager::class), 'otp:');

API Reference

UuidGenerator

Method Signature Returns
v1() static v1(): Uuid Time-based UUID
v3() static v3(Uuid|string $ns, string $name): Uuid MD5 name-based UUID
v4() static v4(): Uuid Random UUID
generate() static generate(int $version, mixed ...$args): Uuid Generic factory

Uuid

Method Signature Returns
__construct() __construct(string $bytes) — (must be 16 bytes)
fromString() static fromString(string $uuid): self Parsed UUID
fromBytes() static fromBytes(string $bytes): self From binary
toString() toString(): string Canonical string
getBytes() getBytes(): string 16 raw bytes
getVersion() getVersion(): int 1, 3, 4
getVariant() getVariant(): int 0=NCS, 1=RFC, 2=MS, 3=Reserved

UlidComponent

Method Signature Returns
generate() generate(?DateTimeHandler $ts = null): string ULID string
generateMonotonic() generateMonotonic(?DateTimeHandler $ts = null): string Strictly-increasing ULID
validate() validate(string $ulid): bool —
parse() parse(string $ulid): array [timestamp, randomnessHigh, randomnessLow, timestampDateTime]
extractTimestampMs() extractTimestampMs(string $ulid): int Unix ms
extractTimestampDateTime() extractTimestampDateTime(string $ulid): DateTimeHandler —

OtpGenerator

Method Signature Returns
numeric() numeric(int $length): string Digits
alphanumeric() alphanumeric(int $length): string Letters + digits
alphabetic() alphabetic(int $length): string Letters
characterBased() characterBased(int $length, ?string $chars): string Custom alphabet
generateWithExpiry() generateWithExpiry(int $length, string $type = 'numeric', int $ttl = 300, ?string $chars = null): array ['otp'=>..., 'expires_at'=>...]
verify() verify(string $input, string $stored, ?int $expiresAt = null): bool —
generateTotpSecret() generateTotpSecret(int $bytes = 20): string Base32 string
getTotp() getTotp(string $secret, int $win = 30, int $digits = 6): string Current code
verifyTotp() verifyTotp(string $input, string $secret, int $win = 30, int $digits = 6, int $drift = 1): bool —

OtpCacheManager

Method Signature Returns
__construct() __construct(?CacheManager $cache = null, string $prefix = 'otp_') —
generateAndStore() generateAndStore(string $id, int $length, string $type = 'numeric', int $ttl = 300, ?string $chars = null): string The plain-text OTP
verify() verify(string $id, string $otp): bool —
delete() delete(string $id): bool —
getRemainingTtl() getRemainingTtl(string $id): ?int Seconds or null

Comparison Tables

UUID vs ULID

Feature UUID v4 UUID v1 ULID
Length 36 chars 36 chars 26 chars
Sortable by time ❌ ✅ (roughly) ✅
Leaks timestamp ❌ ✅ ✅
Case-sensitive ❌ (hex) ❌ ✅ (Base32)
URL-safe ✅ (-) ✅ ✅
DB storage 36 B (or 16 B binary) 36 B 26 B
Best for Max randomness Legacy Primary keys

OTP Types

Type Alphabet Bits/char 6-char entropy Use
Numeric 0–9 3.32 ~20 bits SMS, email
Alphabetic A–Z, a–z 5.7 ~34 bits Voice / apps
Alphanumeric 0–9, A–Z, a–z 5.95 ~36 bits Better security
Custom Your set log₂(n) varies Compliance rules

Common Pitfalls

  1. Comparing UUIDs as strings with == vs === — always use the canonical lowercase form (Uuid::fromString() normalizes to lowercase).
  2. Forgetting to delete() an OTP after successful verification — leads to replay attacks.
  3. Returning the OTP in the API response — a huge security hole. Send it out-of-band.
  4. Using generateMonotonic() across multiple processes — the monotonic counter is static per process, so multiple PHP-FPM workers each maintain their own state. Use generate() (random) or a shared monotonic source (e.g., Redis INCR) if strict global ordering is required.
  5. Storing TOTP secrets in plaintext — encrypt them with a KMS or libsodium.
  6. Trusting the client clock for TOTP — servers should rely on time() only; the ±1 drift window covers small skews.
  7. Using ULIDs as security tokens — they leak creation time and are predictable in prefix. Use a random token (or UUID v4) for anything secret.
  8. 32-bit PHP — some ULID paths (like 1 << 40) will overflow. Stick to 64-bit builds; the component was designed for modern 64-bit PHP.

FAQ

Q: Can I generate a UUID v5 (SHA-1 name-based)? Not yet — the current UuidGenerator implements v1, v3, and v4. A v5 implementation would look identical to v3 but use sha1(..., true) and set the version nibble to 0x50. You can subclass or add it in your project.

Q: Is UuidGenerator::v1() safe in a cluster? Partially. Each node uses a random node and random clock sequence on every call, so cross-node collisions are extremely unlikely. For strict RFC compliance (stable node per host), pass a stable node ID — the current implementation does not expose that parameter.

Q: What's the difference between OtpGenerator::verify() and OtpCacheManager::verify()? OtpGenerator::verify() compares two strings you already have (one supplied, one stored elsewhere — you manage storage). OtpCacheManager::verify() looks up the stored OTP in cache for you, keyed by identifier, and compares using hash_equals.

Q: Does ULID support microsecond precision? The ULID spec is millisecond precision only. Sub-millisecond ordering is handled by the randomness component (and by monotonic generation).

Q: How many digits should my TOTP use? Stick to 6 for maximum client compatibility (Google Authenticator, Authy, etc.). 8 is fine if you control the client too.

Q: What happens if my CacheManager doesn't implement store()->getTtl()? OtpCacheManager::getRemainingTtl() returns null. Everything else still works.

Clone this wiki locally