iliaal / fast_uuid
Fast RFC 9562 UUID generation (v1/v2/v3/v4/v5/v6/v7/v8 + nil/max) as a PHP C extension, with a ramsey/uuid-shaped object API and procedural fast-path functions.
Package info
Type:php-ext
Ext name:ext-fast_uuid
pkg:composer/iliaal/fast_uuid
Requires
- php: >=8.1
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-15 14:50:18 UTC
README
A PHP C extension for RFC 9562 / RFC 4122 UUID generation. Recorded benchmarks measured 11x to 57x faster than ramsey/uuid on v1/v4/v7 generation and 7x to 11x faster on parsing; see the version caveat in Benchmarks. It produces versions 1, 2 (DCE Security), 3, 4, 5, 6, 7, 8, plus nil and max. The engine is pure C (no C++/libstdc++). The object API follows the shape of ramsey/uuid under the FastUuid namespace, and procedural functions skip object allocation for the hottest call sites.
Full API reference with runnable examples: docs/index.html. Benchmarks: BENCHMARKS.md.
⚡ Why it's fast
- Userspace CSPRNG: an 8 KB per-thread buffer is refilled by an AES-256-CTR generator with fast key erasure, running on the CPU's AES instructions (x86/x86-64 AES-NI, ARMv8 Crypto Extensions) and reseeded from the OS CSPRNG every 64 KiB, so one syscall covers ~4,000 v4s. CPUs without AES instructions refill the buffer from the OS CSPRNG (
getrandom()on Linux) directly, one syscall per ~500 v4s. ramsey's per-callrandom_bytes()is the usual bottleneck. - No property table: the object is 16 inline bytes plus a lazily-cached canonical string. No
HashTable, no declared properties, custom create/free/clone/compare/cast handlers. - SIMD hex formatter: x86-64 uses a runtime-dispatched SSSE3
pshufb-LUT path, and ARM64 uses a NEON table-lookup path. Both turn 16 bytes into 32 hex in a handful of vector ops, with a scalar LUT fallback for other architectures. - Procedural path:
uuid_v4()and friends return azend_stringwith no object allocation, for ORM inserts and cache keys. On PHP 8.4+ direct calls compile to frameless calls that skip the call frame and argument parser (up to ~20% faster per call; see BENCHMARKS.md).
Requirements
- PHP 8.1 through 8.6, NTS or ZTS. PHP 8.1/8.2/8.3 build via small
#if PHP_VERSION_IDpolyfills. - x86-64 and ARM64 get the SIMD formatter automatically; other architectures fall back to the scalar path. No build flags needed either way.
- No external libraries. v1 and v6 use an internal RFC-compliant generator with a random node (multicast bit set, per RFC 9562 §5.1). v3 and v5 use PHP's bundled MD5/SHA1.
📦 Install
The quickest path is PIE, which resolves a prebuilt
binary for your platform (Windows x86/x64 NTS/TS, Linux glibc
x86_64/arm64, macOS arm64) and falls back to a source build otherwise:
pie install iliaal/fast_uuid
Then enable it with extension=fast_uuid in your php.ini. Load it with extension= rather than dl(): a dl()-loaded copy runs without frameless calls (PHP never unregisters them), and on musl an unloaded copy leaves a dangling fork handler.
🛠️ Build from source
phpize ./configure --enable-fast-uuid make make test php -d extension="$(pwd)/modules/fast_uuid.so" -r 'echo \FastUuid\Uuid::uuid4(), "\n"; echo uuid_v7(), "\n";'
The SIMD hex formatter and the AES-256-CTR CSPRNG backend are chosen at runtime from the CPU's feature flags. To build without them, add a define to CFLAGS before running ./configure:
-DFU_DISABLE_AESremoves only the AES backend; the buffer is refilled from the OS CSPRNG.-DFU_DISABLE_SSSE3(x86) or-DFU_DISABLE_NEON(ARM64) removes the SIMD formatter and, with it, the AES backend for that architecture.
php --ri fast_uuid shows the active CSPRNG backend and, after a fallback, the reason.
The arginfo header is generated from fast_uuid.stub.php. To regenerate after editing the stub:
php /path/to/php-src/build/gen_stub.php fast_uuid.stub.php
Object API: FastUuid\Uuid
Static factories (all return FastUuid\UuidValueInterface, except isValid()):
uuid1(int|string|null $node = null, ?int $clockSeq = null)
uuid2(int $localDomain, int|string|null $localIdentifier = null, int|string|null $node = null, ?int $clockSeq = null)
uuid3(object|string $ns, string $name)
uuid4()
uuid5(object|string $ns, string $name)
uuid6(int|string|null $node = null, ?int $clockSeq = null)
uuid7(int|DateTimeInterface|null $dateTime = null) // int = unix milliseconds
uuid8(string $bytes) // 16 raw bytes
fromString(string $uuid) // canonical, urn:uuid:, {braced}, bare 32-hex, any case
fromBytes(string $bytes) // 16 raw bytes
fromInteger(string $integer) // decimal string
fromHexadecimal(Stringable|string $hex) // 32 hex chars; Stringable covers ramsey's Type\Hexadecimal
fromDateTime(DateTimeInterface $dt, int|string|null $node = null, ?int $clockSeq = null)
isValid(string $uuid): bool
Instance methods:
toString(): string __toString(): string getBytes(): string getHex(): string
getUrn(): string getVersion(): ?int getVariant(): int getInteger(): string
getDateTime(): DateTimeImmutable getFields(): array equals(mixed): bool
compareTo(mixed): int jsonSerialize(): string getTimestampMillis(): int
toBytes(): string toHexadecimal(): string toUrn(): string toInteger(): string
toBytes()/toHexadecimal()/toUrn()/toInteger()are aliases ofgetBytes()/getHex()/getUrn()/getInteger(), matching theget*→to*naming of the newerramsey/identifierlibrary.getTimestampMillis()returns the embedded timestamp as unix milliseconds for RFC time-based versions (v1, v2, v6, v7) and is much cheaper thangetDateTime()since it builds no object; it throwsUnsupportedOperationExceptionfor non-time-based versions and non-RFC variants.Uuid::uuid7()accepts a unix-millisecondintas well as aDateTimeInterface, which skips the DateTime machinery entirely. The proceduraluuid_v7_at(int $unixMillis)is the fastest explicit-timestamp form.- Live-clock
uuid1()anduuid6()share a timestamp high-water mark. If the clock stalls or moves backward, the next timestamp advances by 100 ns while preserving any supplied node and clock sequence. This prevents repeats within one process, or one thread under ZTS. You must coordinate node and clock-sequence values across processes, forks, threads, and restarts when you supply fixed values.fromDateTime()preserves your explicit timestamp and does not use this monotonic state. - Live-clock
uuid2()keeps its own high-water mark on the visible 28-bit timestamp portion (time_mid+time_hi;time_lowis the local identifier, so the shared v1/v6 mark would be invisible until a bit-32 carry), but only when you supply bothnodeandclockSeq. A defaulted node or clock sequence is freshly random per call, so those callers never repeat and their output is unchanged. When the mark applies and the clock stalls or moves backward, the next visible timestamp advances by one unit, which is 2^32 × 100 ns ≈ 429.5 s of embedded time per call:getDateTime()/getTimestampMillis()read back the advanced time. The 28-bit visible field exhausts after roughly 2.36×10^8 stalled calls from the current epoch, after whichuuid2()throwsUnsupportedOperationException("v2 timestamp exhausted") until the worker exits. As with v1/v6, you must coordinate the local identifier, node, and clock sequence across processes, forks, threads, and restarts when you supply fixed values. - Live-clock UUIDv7 carries sub-millisecond precision (RFC 9562 §6.2 Method 3):
rand_aencodes the sub-ms fraction. When the millisecond/sub-millisecond time key repeats or moves backward, the counter inrand_badvances by a fresh CSPRNG-selected value from1..2^24inclusive, carrying into the time key on overflow. Single and batch calls share this monotonic state within a process, or within a thread under ZTS; ties across threads or processes carry no order. Explicituuid7($dateTime)anduuid7($unixMillis)calls do not use the monotonic state.getDateTime()reads back at millisecond precision, matchingramsey/uuid. getVariant()returns0(NCS),2(RFC 4122),6(Microsoft),7(future);getVersion()isnullfor nil/max and non-RFC variants.getDateTime()works for RFC time-based versions (v1, v2, v6, v7) and throwsFastUuid\Exception\UnsupportedOperationExceptionfor non-time-based versions and non-RFC variants.getFields()returns an associative array of hex strings (time_low,time_mid,time_hi_and_version,clock_seq_hi_and_reserved,clock_seq_low,node). For the ramsey-shapedFieldsInterface/Typeobjects, use the compat layer below.equals()andcompareTo()accept another UUID object (native, a compat wrapper, or anyStringablewhose string form parses as a UUID) or its canonical string.uuid3()anduuid5()acceptobject|stringnamespaces. Native UUIDs resolve directly; wrappers with a compatible public, non-staticgetCore()callable without arguments resolve through its native UUID result. OtherStringableobjects resolve through their string form. ForeigngetBytes()methods are not used, because those bytes may have GUID or ordered-time storage order. The same resolution applies to nativeequals()andcompareTo(). Ordinary PHP weak string coercion applies to scalar namespace arguments;strict_types=1rejects scalars other than strings withTypeError.var_dump()shows the value as a virtualuuidproperty, andvar_export()output rebuilds throughUuid::__set_state().
Constants: NIL, MAX, NAMESPACE_DNS, NAMESPACE_URL, NAMESPACE_OID, NAMESPACE_X500, DCE_DOMAIN_PERSON, DCE_DOMAIN_GROUP, DCE_DOMAIN_ORG.
Implements FastUuid\UuidValueInterface, which extends the lightweight
FastUuid\UuidInterface namespace marker with the complete native instance
contract. Factory return types use UuidValueInterface, while existing
userland UuidInterface implementations remain valid namespace arguments.
DCE Security (v2)
$u = \FastUuid\Uuid::uuid2(\FastUuid\Uuid::DCE_DOMAIN_PERSON); // local id auto-fills from POSIX uid $u->getVersion(); // 2 $u = \FastUuid\Uuid::uuid2(\FastUuid\Uuid::DCE_DOMAIN_GROUP, 4242);
The local identifier occupies bytes 0 to 3 (big-endian); the local domain is stored in byte 9. With domain PERSON or GROUP and a null identifier, the extension uses the process uid or gid; on Windows, where there is no POSIX uid/gid, an explicit localIdentifier is required.
Exceptions
FastUuid\Exception\InvalidArgumentException(extends\InvalidArgumentException): a bad length, node, or integer.FastUuid\Exception\InvalidUuidStringException(extends the above): an unparseable UUID string.FastUuid\Exception\UnsupportedOperationException(extends\LogicException, matchingramsey/uuid4.x): raised bygetDateTime()on a non-time-based version.
All three implement FastUuid\Exception\UuidExceptionInterface (extends \Throwable), so one catch covers everything the extension throws.
Out-of-range factory inputs are rejected, not silently truncated: a v7 timestamp past the 48-bit millisecond field, a fromDateTime instant outside the v1 Gregorian window, a non-canonical or >128-bit decimal string for fromInteger, a node outside 0..2^48-1, a clock sequence outside 0..0x3fff, a DCE domain outside 0..255, a uuid3()/uuid5() name over 16 MiB, or uuid2 without an explicit local identifier for a non-PERSON/GROUP domain all throw InvalidArgumentException. Range is checked after type: a node or localIdentifier of the wrong type raises TypeError from the parameter parser, since both are declared int|string|null.
Procedural API
uuid_v1() uuid_v3($ns, $name) uuid_v4() uuid_v4_fast() uuid_v5($ns, $name) uuid_v6() uuid_v7() uuid_v8($bytes)
uuid_v7_at($unixMillis) // v7 from a unix-millisecond int (no DateTime)
uuid_v1_bin() uuid_v4_bin() uuid_v6_bin() uuid_v7_bin() uuid_v4_fast_bin() // raw 16 bytes, no string
uuid_v3_bin($ns, $name) uuid_v5_bin($ns, $name) uuid_v8_bin($bytes) uuid_v7_at_bin($unixMillis)
uuid_v4_batch($n) uuid_v7_batch($n) // array of $n canonical strings
uuid_v4_bin_batch($n) uuid_v7_bin_batch($n) // array of $n raw 16-byte values
uuid_to_bin($uuid) // canonical/parsed string -> 16 raw bytes
uuid_from_bin($bytes)// 16 raw bytes -> canonical string
uuid_is_valid($uuid) // bool
fast_uuid_random_bytes($length) // batched CSPRNG bytes, $length > 0
uuid_v4_fast() uses a non-cryptographic xoshiro256** PRNG. Use it only for non-security IDs.
Batch count is capped at 100,000 UUIDs per call, and fast_uuid_random_bytes() is capped at 16 MiB per call.
UUIDv7 batches read the clock once per call and advance the same random-increment counter as single calls, so a large batch's timestamps can trail the wall clock by the batch duration.
ramsey/uuid compatibility layer (FastUuid\Compat)
compat/ is a PSR-4 (FastUuid\Compat\) companion package (iliaal/fast-uuid-compat) that provides the cold-path ramsey ergonomics on top of the C engine. It ships in this repo's compat/ directory and is not on Packagist yet; install it as a Composer path repository (composer config repositories.fast-uuid-compat path /path/to/fast_uuid/compat && composer require iliaal/fast-uuid-compat:@dev) or autoload FastUuid\Compat\ to compat/src/. It provides: UuidFactory / UuidFactoryInterface, the per-version Rfc4122\UuidV1…UuidV8 / NilUuid / MaxUuid / Nonstandard\Uuid classes, Rfc4122\UuidV2 with getLocalDomain() / getLocalIdentifier() / getLocalDomainName(), Rfc4122\UuidV6 with fromUuidV1() / toUuidV1(), Rfc4122\Fields and Nonstandard\Fields (FieldsInterface), Type\Hexadecimal / Type\Integer with their TypeInterface / NumberInterface contracts, the codecs (StringCodec, OrderedTimeCodec, TimestampFirstCombCodec, TimestampLastCombCodec, GuidStringCodec), Guid\Guid, the providers (RandomGeneratorInterface, NodeProviderInterface, TimeGeneratorInterface + defaults), and the validators (GenericValidator, NonstandardValidator). Compat UUIDs implement both modern magic serialization and Ramsey's legacy Serializable methods. Node arguments accept Type\Hexadecimal, and UUIDv2 local identifiers accept Type\Integer.
To migrate from ramsey/uuid, swap use Ramsey\Uuid\Uuid for use FastUuid\Compat\Uuid; most code needs nothing else. The compat package depends only on the extension.
Compatibility covers the API shape; generation algorithms and some codec conventions differ. In particular, live-clock v7 uses sub-millisecond time and the random increments described above, and GUID text follows the canonical RFC form described below.
Generation stays on the pure-C fast path. A custom RandomGeneratorInterface, TimeGeneratorInterface, or NodeProviderInterface routes the affected factories off it (ramsey behaviour), so your generators win. Custom node providers feed uuid1(), uuid2(), uuid6(), and fromDateTime() when you pass no explicit node. The compat Uuid::uuid7() facade also accepts a unix-millisecond int, matching the core object API.
Compat uuid3() and uuid5() retain UuidInterface|string namespace parameters. String namespaces identify network-order UUIDs independently of the output codec, including under COMB codecs and on subclasses that inherit fromString(). Overriding getCodec() does not change namespace identity; an explicit fromString() override retains the subclass parsing path. To use codec-formatted text as a namespace, decode it with $factory->fromString($text) first and pass the resulting UUID object.
Decoded UUID objects keep the factory's active codec for strings, bytes, and serialization, and derive identity forms from toString() as ramsey does: getUrn() is 'urn:uuid:' . toString() and getHex() is toString() without the hyphens, while getInteger() always reads the network-order core. equals() takes any value and returns false for non-UUID input instead of throwing. compareTo() accepts UUID objects only, orders by the network-order bytes regardless of codec, and throws InvalidArgumentException otherwise. Under the default codec all of these match the canonical form, so nothing changes unless you set a codec.
GuidStringCodec swaps the first three fields in encodeBinary() / decodeBytes() only. A GUID is mixed-endian in its byte array but its text is the plain RFC string, which is why .NET's Guid.ToString() and Guid.ToByteArray() disagree. Under that codec, pass GUID-ordered bytes to fromBytes() and canonical text to fromString(); Guid\Guid::getBytes() returns the same mixed-endian order. OrderedTimeCodec and the COMB codecs change their documented binary storage order, and the COMB codecs also reshape the text, so their getHex() follows it.
Validation is strict by default: GenericValidator enforces the version nibble (1-8) and the variant nibble (8/9/a/b), with the nil UUID explicitly exempt; NonstandardValidator stays shape-only with no nibble constraints. UuidInterface has no required getCore(), so third-party Ramsey implementations and doubles can satisfy it. Internals use a compatible optional getCore() returning a native UUID when available (AbstractUuid and Guid\Guid expose one), then fall back to the implementation's string form.
📊 Benchmarks
These measurements predate the v1/v6 timestamp guard and the v7 random-increment fix. They are historical results, not measurements of the current generation paths.
Throughput against ramsey/uuid 4.9.2 and the PECL uuid extension 1.3.0
(libuuid-backed). PHP 8.4.22 NTS, non-debug, no sanitizers; SSSE3 hex formatter
active (x86-64). Each operation runs 300,000 iterations after a 20,000-iteration
warmup; reported figure is the best of 40 runs. Million ops/sec, higher is
better:
| Operation | fast_uuid (obj) | fast_uuid (proc) | ramsey/uuid | PECL uuid |
|---|---|---|---|---|
| v4 gen→string | 12.6 | 19.5 | 1.10 | 0.47 |
| v1 gen→string | 12.3 | 16.5 | 0.29 | 8.22 |
| v7 gen→string | 12.1 | 19.8 | 0.66 | n/a |
| parse→16 bytes | 23 | 36 | 3.18 | 5.28 |
Speedup over ramsey/uuid: v4 11.5x to 17.7x, v1 42x to 57x, v7 18.3x to 30x,
parse 7.2x to 11.3x.
Generating many at once amortizes the per-call overhead. The batch functions
return an array of 100 per call; procedural binary forms (uuid_v4_bin() etc.)
skip canonical formatting and return raw 16-byte strings. Million UUIDs/sec:
| Batch operation | fast_uuid (proc) |
|---|---|
uuid_v4_batch |
22.5 |
uuid_v7_batch |
52 |
uuid_v4_bin_batch |
25 |
uuid_v7_bin_batch |
79 |
The fast_uuid operations are fast enough (~50 ns) that scheduler noise
dominates a single run, so read the fast_uuid columns as order-of-magnitude,
not three-significant-digit (roughly ±10% run-to-run). ramsey/uuid (~900 ns)
and PECL (~2 µs) reproduce to within ~3%. Full table, the ARM64/NEON numbers,
the timestamp/DateTime API breakdown, and how to reproduce are in
BENCHMARKS.md.
Testing
make test # run-tests.php against the built .so
The suite (tests/*.phpt) covers every version, all parse forms, per-version getDateTime, fields/integer, node/clockSeq, the exception hierarchy, the procedural functions, the SIMD formatter, and the full compat layer. Verified green on PHP 8.1 / 8.2 / 8.3 / 8.4 / 8.4-ZTS / 8.5 / 8.6 (0 compiler warnings) and clean under an ASan/UBSan-instrumented build.
Contributing
Build instructions, the stub-to-arginfo workflow, and the test conventions are in CONTRIBUTING.md. Run the suite against more than one PHP version when you touch C, and add an ASan/UBSan run for any change to a parse, format, or generation path.
Security
Report a vulnerability by email to ilia@ilia.ws. Details and scope are in SECURITY.md.
🔗 Native PHP extensions
Companion native PHP extensions:
- php_excel: native Excel I/O via LibXL. 7-10× faster than PhpSpreadsheet, full XLS/XLSX with formulas, formatting, and styling.
- mdparser: native CommonMark + GFM markdown parser via md4c. 15-30× faster than pure-PHP libraries.
- php_clickhouse: native ClickHouse client speaking the wire protocol directly. Picks up where SeasClick left off.
- pdo_duckdb: PDO driver for DuckDB, analytical SQL in your PHP stack.
- fastjson: drop-in faster
ext/json, backed by yyjson. 6× encode, 2.7× decode, 5× validate. - phpser: decoder-optimized binary serializer for cache workloads. Faster than igbinary on packed numerics and DTO batches.
- fastchart: native chart-rendering extension. 38 chart types behind one fluent OO API, SVG-canonical with PNG/JPG/WebP and optional PDF output.
- statgrab: system statistics (CPU, memory, disk, network) via libstatgrab, no parsing /proc by hand.
- phonetic: native phonetic name matching (Double Metaphone, Beider-Morse, Daitch-Mokotoff, NYSIIS, Match Rating), the encoders PHP core lacks.
License
BSD-3-Clause. See LICENSE.
Follow @iliaa on X • Blog • If this sped up your UUID generation, ⭐ star it!
