Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ $b = TypeID::fromString('user_01jsnsf2g7e2saxdjvz3j6tc3x');
echo $a->equals($b); // true

// Round-trip a UUID stored in a binary(16) database column
$uuidBytes = random_bytes(16);
$uuidBytes = TypeID::generate('user')->bytes();
$binaryId = TypeID::fromBytes($uuidBytes, 'user');
$uuidBytes = $binaryId->bytes();
```
Expand All @@ -69,7 +69,7 @@ The package uses `ramsey/uuid` to generate standards-compliant UUIDv7 values. En

`fromUuid()` also accepts valid non-v7 UUIDs for interoperability. Those imported values—and the nil value returned by `zero()`—do not gain UUIDv7 chronological ordering merely by being encoded as TypeIDs.

All package exceptions implement `TypeID\Exception\TypeIDException`, allowing callers to catch construction and validation failures through one stable contract.
Caller-invalid input throws `TypeID\Exception\ValidationException`, which extends `InvalidArgumentException`. `TypeID\Exception\ConstructorException` is reserved for UUID generation failures. Both implement `TypeID\Exception\TypeIDException`.

## Format

Expand Down
74 changes: 46 additions & 28 deletions src/Base32.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@

namespace TypeID;

use InvalidArgumentException;
use TypeID\Exception\ValidationException;

/**
* Crockford base32 encoder/decoder for TypeID suffixes.
*
* Converts a 128-bit UUID to/from a 26-character string using Crockford's
* alphabet (0-9, a-z minus i, l, o, u). Pure bit manipulation — no GMP
* or bcmath required.
*
* Bit layout — 16 UUID bytes (128 bits) → 26 × 5-bit chars:
*
* c[ 0] = b[0]>>5 bits 127-125 (top 2 always 0 → max char is '7')
Expand All @@ -33,7 +29,6 @@ final class Base32
{
private const string ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';

/** Reverse lookup: Crockford char → 5-bit integer value. */
private const array DECODE_MAP = [
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
Expand All @@ -49,28 +44,39 @@ private function __construct() {}
/**
* Encode a UUID string to a 26-char Crockford base32 suffix.
*
* @throws InvalidArgumentException If $uuid is not a valid UUID.
* @throws ValidationException If $uuid is not a valid UUID.
*/
public static function encode(string $uuid): string
{
if (! Validator::isValidUuid($uuid)) {
throw new InvalidArgumentException('Invalid UUID string: '.$uuid);
throw new ValidationException(
'Invalid UUID string: '.Validator::formatForMessage($uuid)
);
}

$binary = hex2bin(str_replace('-', '', strtolower($uuid)));
return self::encodeBytes(hex2bin(str_replace('-', '', strtolower($uuid))));
}

if ($binary === false) {
throw new InvalidArgumentException('Invalid UUID string: '.$uuid);
/**
* Encode 16 raw UUID bytes to a 26-char Crockford base32 suffix.
*
* @throws ValidationException If $bytes is not exactly 16 bytes.
*/
public static function encodeBytes(string $bytes): string
{
if (strlen($bytes) !== 16) {
throw new ValidationException(
'UUID bytes must be exactly 16 bytes, got '.strlen($bytes)
);
}

$bytes = unpack('C*', $binary);
$unpacked = unpack('C*', $bytes);

if ($bytes === false) {
throw new InvalidArgumentException('Invalid UUID string: '.$uuid);
if ($unpacked === false) {
throw new ValidationException('Failed to unpack UUID bytes');
}

/** @var int[] $b */
$b = array_values($bytes);
$b = array_values($unpacked);

$a = self::ALPHABET;

Expand Down Expand Up @@ -107,18 +113,38 @@ public static function encode(string $uuid): string
* Decode a 26-char Crockford base32 suffix to its canonical UUID string.
* Input is strict: lowercase only, with no ambiguous Crockford characters.
*
* @throws InvalidArgumentException If $base32 is not a valid 26-char Crockford string.
* @throws ValidationException If $base32 is not a valid 26-char Crockford string.
*/
public static function decode(string $base32): string
{
if (! Validator::isValidBase32($base32)) {
throw new InvalidArgumentException('Invalid TypeID base32 string: '.$base32);
$hex = bin2hex(self::decodeBytes($base32));

return sprintf('%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}

/**
* Decode a 26-char Crockford base32 suffix to 16 raw UUID bytes.
*
* @throws ValidationException If $base32 is not a valid TypeID suffix.
*/
public static function decodeBytes(string $base32): string
{
if (! Validator::isValidSuffix($base32)) {
throw new ValidationException(
'Invalid TypeID base32 string: '.Validator::formatForMessage($base32)
);
}

$m = self::DECODE_MAP;
$v = array_map(fn (string $ch): int => $m[$ch], str_split($base32));

$hex = bin2hex(pack('C*',
return pack('C*',
$v[0] << 5 | $v[1],
$v[2] << 3 | $v[3] >> 2,
($v[3] & 0x03) << 6 | $v[4] << 1 | $v[5] >> 4,
Expand All @@ -135,14 +161,6 @@ public static function decode(string $base32): string
($v[21] & 0x0F) << 4 | $v[22] >> 1,
($v[22] & 0x01) << 7 | $v[23] << 2 | $v[24] >> 3,
($v[24] & 0x07) << 5 | $v[25],
));

return sprintf('%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
);
}
}
2 changes: 1 addition & 1 deletion src/Exception/ConstructorException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@

use RuntimeException;

/** Thrown when a TypeID cannot be constructed from the given input. */
/** Thrown when TypeID generation fails operationally. */
final class ConstructorException extends RuntimeException implements TypeIDException {}
2 changes: 1 addition & 1 deletion src/Exception/ValidationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@

use InvalidArgumentException;

/** Thrown when a TypeID prefix or suffix fails spec validation. */
/** Thrown when caller input fails TypeID validation. */
final class ValidationException extends InvalidArgumentException implements TypeIDException {}
97 changes: 45 additions & 52 deletions src/TypeID.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@

namespace TypeID;

use Exception;
use InvalidArgumentException;
use JsonSerializable;
use Override;
use Ramsey\Uuid\Exception\UuidExceptionInterface;
use Ramsey\Uuid\Uuid;
use Stringable;
use TypeID\Exception\ConstructorException;
Expand All @@ -31,15 +30,19 @@ final class TypeID implements JsonSerializable, Stringable

/** @throws ValidationException If prefix or suffix fails TypeID spec validation. */
public function __construct(
public readonly string $prefix, // Entity-type label (e.g. 'user', 'order'). Empty string means no prefix.
public readonly string $suffix, // Crockford base32 UUID payload — always exactly 26 lowercase characters.
public readonly string $prefix,
public readonly string $suffix,
) {
if (! Validator::isValidPrefix($this->prefix)) {
throw new ValidationException("Invalid prefix: {$this->prefix}");
throw new ValidationException(
'Invalid prefix: '.Validator::formatForMessage($this->prefix)
);
}

if (! Validator::isValidSuffix($this->suffix)) {
throw new ValidationException("Invalid suffix: {$this->suffix}");
throw new ValidationException(
'Invalid suffix: '.Validator::formatForMessage($this->suffix)
);
}
}

Expand All @@ -49,66 +52,66 @@ public function __toString(): string
return $this->toString();
}

/** @return array{prefix: string, suffix: string} */
public function __serialize(): array
{
return [
'prefix' => $this->prefix,
'suffix' => $this->suffix,
];
}

/**
* @param array<string, mixed> $data
*
* @throws ValidationException If the serialized data is malformed or invalid.
*/
public function __unserialize(array $data): void
{
if (! is_string($data['prefix'] ?? null) || ! is_string($data['suffix'] ?? null)) {
throw new ValidationException('Invalid serialized TypeID data');
}

$validated = new self($data['prefix'], $data['suffix']);

$this->prefix = $validated->prefix;
$this->suffix = $validated->suffix;
}

/**
* Create a TypeID from any valid UUID string (v4, v7, nil, …).
* Uppercase hex is accepted and normalized to lowercase.
*
* @throws ConstructorException If $uuid is not a valid UUID string.
* @throws ValidationException If $prefix fails spec validation.
* @throws ValidationException If $uuid or $prefix fails validation.
*/
public static function fromUuid(string $uuid, ?string $prefix = null): self
{
try {
$suffix = Base32::encode($uuid);
} catch (InvalidArgumentException $e) {
throw new ConstructorException(
'Failed to create TypeID from UUID: '.$e->getMessage(),
previous: $e,
);
}

return new self($prefix ?? '', $suffix);
return new self($prefix ?? '', Base32::encode($uuid));
}

/**
* Create a TypeID from a prefix and raw 16-byte binary UUID.
* Useful for round-tripping UUIDs stored as binary(16) in a database.
*
* @throws ConstructorException If $bytes is not exactly 16 bytes.
* @throws ValidationException If $prefix fails spec validation.
* @throws ValidationException If $bytes or $prefix fails validation.
*/
public static function fromBytes(string $bytes, ?string $prefix = null): self
{
if (strlen($bytes) !== 16) {
throw new ConstructorException(
'UUID bytes must be exactly 16 bytes, got '.strlen($bytes)
);
}

$uuid = Uuid::fromBytes($bytes)->toString();

return self::fromUuid($uuid, $prefix);
return new self($prefix ?? '', Base32::encodeBytes($bytes));
}

/**
* Parse a TypeID from its canonical string form.
* Accepts prefixed ('user_01jsnsf2g7…') and bare ('01jsnsf2g7…') forms.
* The last underscore is always the prefix/suffix delimiter.
*
* @throws ConstructorException If $value is empty, malformed, or fails spec validation.
* @throws ValidationException If $value is malformed or fails spec validation.
*/
public static function fromString(string $value): self
{
try {
[$prefix, $suffix] = Validator::parseTypeID($value);
[$prefix, $suffix] = Validator::parseTypeID($value);

return new self($prefix, $suffix);
} catch (InvalidArgumentException $e) {
throw new ConstructorException(
'Failed to create TypeID from string: '.$e->getMessage(),
previous: $e,
);
}
return new self($prefix, $suffix);
}

/**
Expand All @@ -123,14 +126,14 @@ public static function generate(?string $prefix = null): self
{
try {
$uuid = Uuid::uuid7()->toString();
} catch (Exception $e) {
} catch (UuidExceptionInterface $e) {
throw new ConstructorException(
'Failed to generate TypeID: '.$e->getMessage(),
previous: $e,
);
}

return self::fromUuid($uuid, $prefix ?? '');
return self::fromUuid($uuid, $prefix);
}

/**
Expand Down Expand Up @@ -159,7 +162,7 @@ public function toUuid(): string
/** Decode the suffix to raw 16-byte binary — useful for binary(16) database columns. */
public function bytes(): string
{
return Uuid::fromString($this->toUuid())->getBytes();
return Base32::decodeBytes($this->suffix);
}

/** True when this TypeID represents the nil UUID (all 128 bits are zero). */
Expand All @@ -174,16 +177,6 @@ public function isNonZero(): bool
return ! $this->isZero();
}

/**
* True when this TypeID has a non-zero suffix.
*
* @deprecated Use isNonZero() instead; every TypeID has a suffix.
*/
public function hasSuffix(): bool
{
return $this->isNonZero();
}

/** True when this TypeID's prefix exactly matches $prefix (case-sensitive). */
public function hasPrefix(string $prefix): bool
{
Expand Down
Loading