st-all-one / uuidv7
Pure PHP RFC 9562 UUIDv7 for PHP 8.4+ with monotonic generation, parsing and Base62.
Requires
- php: ^8.4
- php-64bit: *
Requires (Dev)
- carthage-software/mago: 1.59.0
- phpstan/phpstan: 2.3.2
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: 13.4.5
- shipmonk/dead-code-detector: ^1.4
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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).
$clockmust be a\Closure(): intreturning Unix milliseconds in the 48-bit range.$randomBytesmust be a\Closure(int): stringreturning exactly the requested number of bytes.- Invalid clock or entropy values throw
InvalidArgumentException. A CSPRNG failure propagates asRandom\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 be0b10. - 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_bis 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()andUuidV7::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.
- Commit and merge the release changes to
main, then confirmmake checkis green. - Keep the first
CHANGELOG.mdheading exactly in the form## [X.Y.Z] - YYYY-MM-DD. For this release:## [0.1.0] - 2026-10-27. - 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-gmporext-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.