Search by

st-all-one / uuidv7

st-all-one

Pure PHP RFC 9562 UUIDv7 for PHP 8.4+ with monotonic generation, parsing and Base62.

v0.1.0 2026-10-27 14:06 UTC

This package is auto-updated.

Last update: 2026-10-27 14:10:58 UTC


README

Pure PHP 8.4 implementation of RFC 9562 UUID version 7: time-ordered identifiers with 48-bit Unix millisecond timestamps, an optional 12-bit counter, and 62 random payload bits.

Documentation for the official 0.1.0 release (2026-10-27).

  • One-call entry point: uuidv7() returns a canonical 36-character string.
  • Immutable value object with strict parsing, validation, comparison and JSON support.
  • Stateful monotonic generator with explicit, injectable clock and entropy.
  • Canonical string, 16-byte binary, 32-character hex and 22-character Base62.
  • No framework, no runtime dependencies, no extensions beyond the PHP core.
  • No global mutable state; deterministic tests inject clock and entropy.
  • Verified against RFC 9562 Appendix A.6 on glibc and musl.

Requirements

  • PHP 8.4 or newer.
  • 64-bit PHP (php-64bit); 32-bit builds are not supported.
  • Composer 2 for installation.
  • No framework, service container, PSR dependency or runtime extension.

Installation

composer require st-all-one/uuidv7

The package is a library and exposes:

Symbol Purpose
uuidv7() Global one-shot function returning a canonical string
StAllOne\UuidV7\UuidV7 Immutable UUIDv7 value object
StAllOne\UuidV7\UuidV7Generator Stateful per-instance monotonic generator

Composer registers the global function through autoload.files. The name is intentionally short and lives in the global namespace. Do not install two packages that declare uuidv7(): PHP cannot redeclare a function, and Composer will fail while loading the second definition. If you only need the namespaced API, use UuidV7 directly.

Quick start

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

$id = uuidv7();

// Example canonical output:
// 01936c0a-5e0c-7b3a-8f9d-2e1c4a6b8d0f
echo $id, PHP_EOL;

uuidv7() uses 74 random bits per call. UUIDs sort by creation time because the timestamp occupies the most significant bits, but calls made in the same millisecond are not guaranteed to be ordered relative to each other. Use UuidV7Generator when strict per-instance ordering is required.

RFC 9562 layout

All integers are unsigned and encoded big-endian.

Bytes Bits Field
0–5 48 Unix timestamp in milliseconds
6 4 Version (0111)
6 4 rand_a[11:8]
7 8 rand_a[7:0]
8 2 Variant (10)
8 6 rand_b[61:56]
9–15 56 rand_b[55:0]

Every parsed value must have version 7 and variant 0b10. The value object is final readonly; field accessors and conversions never expose mutable bytes.

API

Global uuidv7()

function uuidv7(?\Closure $clock = null, ?\Closure $randomBytes = null): string

Returns the canonical lowercase 8-4-4-4-12 string. With no arguments it reads the wall clock and calls random_bytes(10).

  • $clock must be a \Closure(): int returning Unix milliseconds in the 48-bit range.
  • $randomBytes must be a \Closure(int): string returning exactly the requested number of bytes.
  • Invalid clock or entropy values throw InvalidArgumentException. A CSPRNG failure propagates as Random\RandomException.

UuidV7 factories

use StAllOne\UuidV7\UuidV7;

UuidV7::generate($clock = null, $randomBytes = null);
UuidV7::fromTimestamp(int $unixTsMs, ?\Closure $randomBytes = null);
UuidV7::fromParts(int $unixTsMs, int $randA, int $randB);
UuidV7::fromString(string $uuid);
UuidV7::fromBytes(string $bytes);
UuidV7::fromBase62(string $compact);
UuidV7::isValid(string $uuid); // bool, never throws for a string
Factory Input and behavior
generate() 74 random bits; defaults to wall clock and random_bytes()
fromTimestamp() Explicit timestamp, fresh 10-byte entropy for rand_a/rand_b
fromParts() Exact RFC fields: 48-bit timestamp, 12-bit rand_a, 62-bit rand_b
fromString() Canonical 36-character form; uppercase hex accepted and normalized
fromBytes() Exactly 16 big-endian bytes; version and variant are validated
fromBase62() Exactly 22 Base62 characters; values above 128 bits are rejected
isValid() Non-throwing format/version/variant check

Parsing is strict:

  • Length is checked before PCRE or conversion loops.
  • Hyphens are required in the canonical positions; URN, braces and extra characters are rejected.
  • Version must be 7; variant must be 0b10.
  • Field ranges are checked before an object is returned.
  • Invalid input throws InvalidArgumentException.
$uuid = UuidV7::fromString('017f22e2-79b0-7cc3-98c4-dc0c0c07398f');
$same = UuidV7::fromParts(1645557742000, 0xCC3, 0x18C4DC0C0C07398F);
$same = UuidV7::fromBase62('02p5oQZoHTv0zeY5yG21K3');

$uuid->equals($same); // true

Accessors and conversions

$uuid = UuidV7::fromString('017f22e2-79b0-7cc3-98c4-dc0c0c07398f');

$uuid->timestamp(); // 1645557742000 (48-bit Unix milliseconds)
$uuid->version();   // 7
$uuid->variant();   // 2 (0b10)
$uuid->randA();     // 3267 (0xCC3, 12 bits)
$uuid->randB();     // 0x18C4DC0C0C07398F (62 bits)

$uuid->toString();  // 017f22e2-79b0-7cc3-98c4-dc0c0c07398f
(string) $uuid;     // same as toString()
$uuid->toHex();     // 017f22e279b07cc398c4dc0c0c07398f
$uuid->toBytes();   // 16 bytes, big-endian
$uuid->toBase62();  // 02p5oQZoHTv0zeY5yG21K3

$uuid->compareTo($other); // -1, 0, 1 by unsigned binary order
$uuid->equals($other);    // strict 16-byte equality

toString() always returns lowercase. toBase62() always returns exactly 22 characters with leading zeroes, and its lexicographic order matches the unsigned binary order of the UUID. Base62 uses four 32-bit limbs and base 62^5; GMP and BCMath are not required.

JSON and native serialization

UuidV7 implements JsonSerializable and Stringable:

echo json_encode($uuid, JSON_THROW_ON_ERROR);
// "017f22e2-79b0-7cc3-98c4-dc0c0c07398f"

$serialized = serialize($uuid);
$restored = unserialize($serialized, ['allowed_classes' => [UuidV7::class]]);
$restored->equals($uuid); // true

Native unserialization revalidates the byte length, version and variant. Do not unserialize untrusted data with unrestricted allowed_classes.

UuidV7Generator

use StAllOne\UuidV7\UuidV7Generator;

$generator = UuidV7Generator::create();

$first = $generator->next();        // UuidV7
$second = $generator->nextString(); // canonical string

The generator uses rand_a as a 12-bit counter:

  • A new millisecond reseeds the counter from the entropy source.
  • Calls in the same millisecond increment the counter, so successive values from one instance are strictly increasing.
  • Counter overflow (4,096 values in one millisecond) advances the logical timestamp by one millisecond and reseeds. The generator never sleeps or blocks.
  • If the wall clock moves backwards, the generator keeps the last logical timestamp and continues incrementing. Time never moves backwards in the output.
  • Exhausting the 48-bit timestamp space throws RuntimeException.
  • rand_b is fresh 62-bit entropy on every call.

The generator is not thread-safe. Use one instance per thread or process. Ordering is guaranteed only within one instance; two generators can interleave, and processes are independent.

Deterministic tests

Clock and entropy are injectable closures, so no test needs the real clock or real randomness:

use StAllOne\UuidV7\UuidV7;
use StAllOne\UuidV7\UuidV7Generator;

$clock = static fn (): int => 1_645_557_742_000;
$entropy = static fn (int $length): string => str_repeat("\x00", $length);

$uuid = UuidV7::generate($clock, $entropy);
$generator = UuidV7Generator::create($clock, $entropy);

The closures receive no global state. Two generators created with the same clock and the same entropy sequence produce the same UUID sequence. The library has no singleton, static counter, cache or other mutable global state.

Database defaults

Application-side generation is the portable path:

$id = uuidv7(); // pass this value to the INSERT/ORM

Schema builders that accept raw SQL can defer generation to the database:

// Pseudo-code for a schema builder; this is a SQL expression, not a PHP call.
$column->default(new Expression('uuidv7()'));

The string is executed by the database. It requires a database that provides a SQL function named uuidv7(); PostgreSQL 18+ has one, while older PostgreSQL versions and other engines need an extension, trigger or application-side generation. This library does not install SQL functions and does not depend on a specific database.

Do not call the PHP function inside a migration default:

// Wrong: evaluates once at migration time and freezes one UUID for every row.
$column->default(uuidv7());

Security and uniqueness

UUIDv7 is not a secret. It embeds the creation time in plaintext.

  • Unordered uuidv7() and UuidV7::generate() use 74 random bits per UUID.
  • The monotonic generator keeps 62 random bits per UUID and uses 12 bits for the counter.
  • Collision resistance is probabilistic. Within one generator, the counter prevents duplicates until timestamp exhaustion. Across processes or instances, uniqueness relies on the random payload and timestamp.
  • Do not use UUIDv7 as a password reset token, API key, capability token or any value that must be unguessable. Use a CSPRNG-backed token instead.
  • Length checks precede regex and Base62 loops. Native deserialization revalidates length, version and variant.
  • Compare UUIDs with equals() for equality; compareTo() is an ordering operation, not a secret-comparison primitive.

Performance

This is scalar PHP. Real generation is dominated by random_bytes(); the codec itself is sub-microsecond. Baseline on the reference machine (PHP 8.4.27, x86_64, PCOV/assertions off, OPcache CLI off, glibc):

Case Median
uuidv7() (real clock + entropy) ≈ 2.9 µs
UuidV7::generate() (fixed clock + entropy) ≈ 0.51 µs
UuidV7Generator::next() (fixed) ≈ 0.66 µs
Parse canonical string ≈ 0.47 µs
Format canonical string ≈ 0.18 µs
Encode Base62 ≈ 1.66 µs
Decode Base62 ≈ 1.54 µs
Round-trip parse + Base62 ≈ 3.9 µs

Real generation is suitable for application-side identifiers, not for a database at millions of UUIDs per second. Full numbers for glibc and musl, plus the before/after A/B, are in BENCHMARKS.md.

Runtime tuning

The library does not change php.ini. For production, keep OPcache enabled and compile without assertions (zend.assertions=-1); the test suite runs with assertions enabled. PHP 8.4 disables JIT by default, and this workload is dominated by random_bytes(), so enable opcache.jit=tracing only after measuring it on the same image and host. There is no JIT-specific code path in the library.

Testing and quality gates

Everything runs inside two pinned images:

Base Image
glibc (Debian trixie) php:8.4.27-fpm-trixie@sha256:af172814…ff149c2
musl (Alpine 3.24) php:8.4.27-fpm-alpine3.24@sha256:93aa3f61…5076c00d
make build          # build glibc and musl development images
make install        # composer install from composer.lock
make check          # validate + Mago + PHPStan max + PHPUnit on both bases
make coverage       # PCOV statement coverage, fails below 100%
make smoke          # installs the package in a temporary external project
make audit          # composer audit
make bench          # performance baseline on both bases

The suite covers RFC 9562 Appendix A.6, minimum/maximum field vectors, independently calculated Base62 fixtures, strict parsing, native serialization, generator rollback and counter overflow, timestamp exhaustion, and deterministic clock/entropy injection.

For the 0.1.0 release: 70 tests, 42,514 assertions, 170/170 statements (100%); PHPStan level: max without baseline, Mago lint/format clean, Composer validation strict, both libc variants green, consumer smoke test passing, and composer audit clean.

Releasing

Releases are tag-driven and use .github/workflows/release.yml.

  1. Commit and merge the release changes to main, then confirm make check is green.
  2. Keep the first CHANGELOG.md heading exactly in the form ## [X.Y.Z] - YYYY-MM-DD. For this release: ## [0.1.0] - 2026-10-27.
  3. Create and push an annotated tag:
git tag -a v0.1.0 -m "UUIDv7 for PHP 0.1.0"
git push origin v0.1.0

The workflow rejects lightweight tags, tags not reachable from main, and tags whose version does not match the first changelog entry. It then runs make check, make smoke and make audit, creates .tar.gz and .zip source archives plus SHA256SUMS, and publishes a GitHub Release. A version containing - is marked as a prerelease. Packagist picks up the tag when the repository integration is configured.

Never move or delete a published release tag; publish a new patch version instead.

Design constraints

  • One cohesive value object, one generator and one global function; no interfaces, traits, factories or framework adapters.
  • No runtime dependencies and no extensions beyond PHP core.
  • No global mutable state, singleton or static counter.
  • Explicit big-endian pack/unpack; no host-endian assumption.
  • Base62 uses four 32-bit limbs and base 62^5; no ext-gmp or ext-bcmath.
  • No sleep/usleep/active waiting in any path.
  • RFC fixtures are versioned and immutable; a failing vector is an implementation bug, not a fixture to update.

See PLAN.md for the execution plan, AGENTS.md for project rules, and tests/fixtures/README.md for vector provenance.

License

Apache-2.0. See LICENSE. The RFC 9562 test vector and independent Base62 fixtures retain their original provenance as described in tests/fixtures/README.md.