Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Ksuid Laravel Package

tuupola/ksuid

PHP library for generating and parsing KSUIDs: globally unique, K-sortable identifiers with a timestamp component. Produces compact 27-character Base62 strings, useful for database keys and distributed systems where time-ordered IDs matter.

Deep Wiki
Context7

Getting Started

Install

composer require tuupola/ksuid

No extensions, no service provider, no config file — it is a single-purpose library. If you are on Laravel you do not need to register anything; the classes are autoloaded and usable immediately.

First use case — replace auto-increment integers (or UUIDv4) for primary keys where you want IDs that are generated in-process, URL-safe, and roughly sortable by creation time:

use Tuupola\Ksuid;

$id = new Ksuid();

(string) $id;                              // "0ujtsYcgvSTl8PAuAdqWYSMnLOv" (27 chars, base62)
$id->string();                             // same, explicit
$id->timestamp();                          // seconds since the KSUID epoch (not Unix time)
$id->datetime()->format('Y-m-d H:i:s');    // human-readable creation time
$id->bytes();                              // raw 20 bytes for binary storage

Parsing an existing ID — Ksuid::fromString($string) (and fromBytes() for the binary form) reconstructs the object so you can read its timestamp back. This is the operation you will use most after generation: it lets any KSUID column double as a "when was this created" column without a separate index.

Where to look first

  1. README.md — short, accurate, has the canonical examples.
  2. src/Ksuid.php — the whole implementation. Everything worth understanding (the fixed 1_400_000_000 epoch, the 4-byte timestamp prefix, base62 padding) is in this one file. Read it before you build abstractions on top.
  3. tests/ — shows the exact expected string/byte round-trips and the edge cases the author cared about. Useful as a spec when you write your own validation rule.

Mental model: a KSUID is 20 bytes = 4-byte big-endian timestamp (seconds since 2014-05-13T16:53:20Z) + 16 bytes of randomness, rendered as a zero-padded 27-character base62 string. Fixed length + big-endian timestamp is what makes lexicographic string comparison equal chronological comparison.

Implementation Patterns

As an Eloquent primary key

The common Laravel integration is a trait that generates the key on creating and tells Eloquent the key isn't an integer:

namespace App\Models\Concerns;

use Tuupola\Ksuid;

trait HasKsuid
{
    protected static function bootHasKsuid(): void
    {
        static::creating(function ($model) {
            $key = $model->getKeyName();
            $model->{$key} ??= (new Ksuid())->string();
        });
    }

    public function getIncrementing(): bool { return false; }
    public function getKeyType(): string { return 'string'; }
}
Schema::create('orders', function (Blueprint $table) {
    // ASCII + binary collation: see Gotchas. This matters more than the type.
    $table->char('id', 27)->charset('ascii')->collation('ascii_bin')->primary();
    $table->timestamps();
});

Keep the trait's generation inside creating rather than a booted observer so factories and seeders can override the ID for deterministic tests.

Cursor pagination for free

Because the string is fixed-width, plain > comparisons work — no decoding required:

$page = Order::query()
    ->when($cursor, fn ($q) => $q->where('id', '>', $cursor))
    ->orderBy('id')
    ->limit(50)
    ->get();

$next = $page->last()?->id;

This is the single biggest day-to-day win over UUIDv4: keyset pagination on the primary key, which is already the clustered index in InnoDB, so the range scan is sequential. You avoid OFFSET entirely.

Exposing creation time without a column

For log/event/audit tables where you already have a created_at, treat the KSUID timestamp as a cross-check rather than a replacement. But for append-only tables (webhooks, jobs, notifications) where you'd otherwise add a created_at just for display, parse the ID instead:

$createdAt = Ksuid::fromString($webhook->id)->datetime();

Wrap this in a small accessor or a Ksuid::fromString() call inside a resource class so controllers don't parse strings inline.

Generating in bulk

$ids = collect(range(1, 1000))->map(fn () => (new Ksuid())->string());

Generation is pure PHP (base62 encoding + random_bytes), so it's cheap; there's no DB round trip and no lock contention, unlike AUTO_INCREMENT under high write concurrency.

Deterministic IDs in tests

Don't assert on a generated string. Either assert on the shape:

$this->assertMatchesRegularExpression('/^[0-9A-Za-z]{27}$/', $order->id);

…or inject the ID explicitly through your factory (Order::factory()->create(['id' => '0ujtsYcgvSTl8PAuAdqWYSMnLOv'])). See the testing gotcha below — freezing time does not make new Ksuid() deterministic.

Validation rule

Wrap parsing in a custom rule so malformed IDs fail at the request boundary rather than throwing a 500 deep in a controller:

namespace App\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Tuupola\Ksuid;
use Throwable;

class KsuidRule implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        try {
            Ksuid::fromString((string) $value);
        } catch (Throwable) {
            $fail("The {$attribute} is not a valid KSUID.");
        }
    }
}

Use it on route-bound IDs, webhook payloads, and any external input that claims to be a KSUID.

Binary vs string storage

CHAR(27) string BINARY(20)
Readable in psql/tinker/logs yes no
Index size 27 bytes 20 bytes
URL output direct hex/Base62 encode first
Sortable as text yes (with binary collation) yes

For most applications, string wins — debuggability is worth the 7 bytes. Only go binary for very high-volume tables where you've measured the index-size difference mattering. If you go binary, keep a strict conversion boundary (model cast in, cast out) so you never mix the two forms in one column.

Gotchas and Tips

The MySQL collation trap. This is the number-one production bug with KSUIDs in Laravel. The default utf8mb4_unicode_ci / utf8mb4_0900_ai_ci collation is case-insensitive, which means aB... and Ab... compare equal. Two consequences: a unique index can reject two genuinely distinct KSUIDs, and ORDER BY id no longer matches chronological order. Always declare ->charset('ascii')->collation('ascii_bin') (or CHARACTER SET ascii COLLATE ascii_bin) on the column. PostgreSQL's default collation is case-sensitive and fine; SQLite's BINARY collation is fine too. Fix this at migration time — retrofitting it on a large table is a rebuild.

Second-level granularity means it is not a sequence. KSUIDs generated in the same second share a timestamp and sort in random payload order. They are k-sortable, not totally ordered. If you need strict ordering of events within a second, order by (created_at, id) or use a real sequence — don't assume MAX(id) is "the latest row."

timestamp() is not Unix time. It returns seconds since the KSUID epoch (1 400 000 000, i.e. 2014-05-13T16:53:20Z). Use datetime() for anything user-facing. If you store timestamp() and later compare it to time(), you'll be off by ~11+ years.

Don't truncate or pad. The 27-character length is fixed because base62 encoding of 20 bytes is zero-padded to a constant width. Trimming leading 0s — a tempting "cleanup" in an API or URL shortener — silently breaks both uniqueness and sort order.

Case matters everywhere, including routing. KSUIDs are case-sensitive base62. If your URL layer, CDN, or a well-meaning strtolower() normalises them, distinct IDs collide and route model binding returns 404s or the wrong row. Keep them verbatim.

Clock skew is your sortability ceiling. Sortable IDs are only as trustworthy as the machine's clock. If a host's clock jumps backwards, new IDs sort before old ones. Keep NTP healthy on app servers; don't run KSUID generation on a box with a flaky clock and then rely on MAX(id) for "latest".

Carbon::setTestNow() does not affect generation. The library reads the system clock directly, so travelTo()/freezeTime() in tests won't fix the timestamp component. Test the format and the parsing round-trip; test values by injecting IDs.

They are not secrets. The payload is 16 random bytes (good unguessability, ~2^128), but the creation timestamp is plaintext in the ID. Don't treat a KSUID as an opaque capability token in a URL you'd rather not have time-leak — anyone can decode when the row was created.

Comparison shopping. KSUID (27 chars, 16 random bytes, 1s resolution) vs ULID (26 chars, 10 random bytes, 1ms resolution) vs UUIDv7 (36 chars, 74 random bits, 1ms resolution). KSUID gives you more collision headroom per timestamp tick and a smaller alphabet; ULID/UUIDv7 give you millisecond ordering. If your workload generates thousands of rows per second and ordering within a second matters, prefer UUIDv7 or ULID; if you want short, readable, low-collision IDs and second-level ordering is enough, KSUID is the simplest choice in PHP.

Extension points. There's no DI container binding to override, so the clean move is to wrap it: define an IdGenerator interface with a newId(): string method and bind a KsuidGenerator implementation. That gives you a swap point for tests, for switching to ULID later, and for centralising validation. The package's factory (see src/ for the exact surface in your installed version) is where to look if you need to control payload bytes — useful for deterministic fixtures.

Keep the dependency shallow. It's one small class with no framework coupling, so don't build a repository layer around it; a trait plus a validation rule is the right amount of abstraction.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource