visus/cuid2
A PHP implementation of CUID2 for generating secure, URL-safe, collision-resistant identifiers across multiple machines without coordination. Includes instance-based and static APIs plus built-in validation.
Install with Composer and confirm your PHP version first — v7 requires PHP 8.3+ and v6 requires PHP 8.2+:
composer require visus/cuid2
# verify GMP is available (strongly recommended)
php -m | grep -i gmp
Read the README's Architecture section first (it explains what goes into the hash), then the Cuid2 class itself — it is a tiny API surface: new Cuid2($length), Cuid2::generate($length), ->toString(), ->__toString(), and Cuid2::isValid($value, expectedLength: n).
Smoke-test in Tinker before wiring anything up:
Cuid2::generate()->toString(); // "p6p168tx2rxtgyehd3p2wz04"
Cuid2::generate(10)->toString(); // "a1ao2r0lve"
Cuid2::isValid('a1ao2r0lve', expectedLength: 10); // true
First use case: replace auto-incrementing keys on a model whose IDs are exposed in URLs (posts, invoices, upload tokens). CUID2 is URL-safe base36 ([a-z][a-z0-9]{3,31}), so it drops straight into route model binding with no encoding concerns.
// migration
$table->string('id', 24)->primary();
Model keys — use Laravel's built-in trait, not a custom booted() hook.
use Illuminate\Database\Eloquent\Concerns\HasUniqueStringIds;
use Visus\Cuid2\Cuid2;
class Post extends Model
{
use HasUniqueStringIds;
public function uniqueIds(): array
{
return ['id'];
}
public function newUniqueId(): string
{
return Cuid2::generate()->toString();
}
}
HasUniqueStringIds already handles $incrementing = false and $keyType = 'string', and it fires on creating, so the ID is never regenerated on update — safer than writing your own booted()/saving() listener.
Foreign keys must match the column type. foreignIdFor(Post::class) will emit a bigint — use the string form instead:
$table->string('post_id', 24);
$table->foreign('post_id')->references('id')->on('posts')->cascadeOnDelete();
Route binding needs no work, but you can fail fast on malformed segments instead of hitting the DB:
Route::get('/posts/{post}', ShowPost::class)
->where('post', '[a-z][a-z0-9]{23}');
Validation. isValid() makes a clean custom rule; use expectedLength so a 10-char public ID can't be passed where a 24-char key is expected.
class Cuid2Rule implements ValidationRule
{
public function __construct(private int $length = 24) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! Cuid2::isValid($value, expectedLength: $this->length)) {
$fail("The {$attribute} is not a valid identifier.");
}
}
}
Factories and seeders — always call ->toString(). Factories re-evaluate closures per model, so this gives a fresh ID each time:
public function definition(): array
{
return ['id' => Cuid2::generate()->toString()];
}
Non-key uses that pay off daily: idempotency keys for queued jobs, batch_id on uploads, filenames for generated PDFs/exports, and opaque pagination cursors. A Str macro keeps call sites tidy:
Str::macro('cuid2', fn (int $length = 24): string => Cuid2::generate($length)->toString());
Length policy. Use 24 (the default) for primary keys; 10–16 for user-facing tokens where the value is short-lived or rate-limited. 4 is technically valid and hopelessly collision-prone at any real volume.
Isolate it behind one app-level class (app/Support/Id.php) with Id::new() / Id::isValid(). With 44 stars and zero dependents, a one-file seam lets you swap in Symfony\Uid\Ulid or Str::ulid() later without a codebase-wide sed.
isValid() is a format check only. It does not prove the value was generated by this library, nor that it is unique. Never use it as an authorization or "record exists" check — always query the DB.Cuid2 object to Eloquent/PDO/json_encode. The class is Stringable, but json_encode($cuid) on a non-JsonSerializable object serializes public properties and yields {}, and PDO binding of objects is version-dependent. Cast ->toString() at the persistence and serialization boundary; keep the object only for local echo.factory()->count(50000) seeder — minutes instead of seconds. Add RUN docker-php-ext-install gmp (or install-php-extensions gmp) to your image and ext-gmp to your CI matrix.^6; on PHP 8.3+ → ^7. composer require will resolve correctly, but composer.json in a legacy app can silently hold you back.new Cuid2() then repeated ->toString() is cheap — build once, read many. Do not instantiate in a loop "to be safe"; that costs a full SHA3-512 + base conversion each time.created_at (indexed) or use an ordered ULID/UUIDv7 if sortable keys are a hard requirement.Id wrapper in tests.post_abc123), wrap the output yourself and account for the extra length in your column definition.expect($id)->toMatch('/^[a-z][a-z0-9]{23}$/') or assertTrue(Cuid2::isValid($id, 24)).sha3-512 support (checked in the Cuid2 class) or a length outside 4–32 — not a randomness problem. Verify with in_array('sha3-512', hash_algos(), true).How can I help you explore Laravel packages today?