not-empty/ulid-php-lib
A small PHP library for generating ULIDs—universally unique, lexicographically sortable identifiers combining a timestamp with random data. Provides simple APIs for creating, parsing, and validating ULIDs, making them a good fit for database keys and sortable IDs.
Install the package alongside a Laravel app:
composer require not-empty/ulid-php-lib
First places to look:
vendor/not-empty/ulid-php-lib/src/ — the generator class (commonly Ulid) and any factory/parser classes.README.md in the vendor dir — confirm the exact static method names (generate(), create(), fromString(), etc.), since the package is small and the API is minimal.composer.json — confirm the PHP version constraint and whether it pulls in ext-* requirements or symfony/uid.Minimal first use case — treat it as a Str::ulid() substitute generating sortable 26-character IDs:
use NotEmpty\Ulid\Ulid;
$id = (string) Ulid::generate(); // e.g. "01J9Z8K3Q4M5N6P7R8S9T0V1W2"
The most common first application is a database primary key on a new model:
Schema::create('invoices', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->timestamps();
});
Then override ID generation on the model (see next section) so inserts actually use this library rather than Laravel’s default.
Model IDs via a custom trait. Laravel 10+ ships HasUniqueStringIds, whose newUniqueId() is the single seam to override. That is the cleanest integration point instead of fighting HasUlids (which hardcodes Str::ulid()):
use Illuminate\Database\Eloquent\Concerns\HasUniqueStringIds;
use NotEmpty\Ulid\Ulid;
trait HasLibUlids
{
use HasUniqueStringIds;
public function newUniqueId()
{
return (string) Ulid::generate();
}
public function uniqueIds()
{
return [$this->getKeyName()];
}
}
Apply it to models with $incrementing = false; protected $keyType = 'string'; (or rely on the trait’s defaults where available).
Route keys. ULIDs work fine with implicit route model binding as long as the column is a string and the route parameter type is not constrained to integer. Normalize case before binding (see Gotchas).
Business references. Prefer this lib for human-facing references where you want a sortable, timestamp-embedded identifier:
$order = Order::create([
'reference' => (string) Ulid::generate(),
'status' => 'pending',
]);
Because the first 10 characters encode the millisecond timestamp, you can bucket records by time without a separate created_at index, useful for cursor pagination: Order::where('reference', '>', $cursor)->orderBy('reference')->limit(50).
Extracting the timestamp. If the parser exposes it (e.g. ->getDateTime() / ->getTimestamp()), use it to audit when a row was created when created_at is missing or in external payloads. Otherwise decode manually: take the first 10 Crockford Base32 chars, convert to an integer, and treat as Unix milliseconds.
Integration with third parties. Validate incoming ULIDs before persisting:
if (! preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/i', $payload['id'])) {
abort(422);
}
Store uppercased and canonicalize on input to keep string comparison and indexing consistent.
Service layer / DTOs. For domain objects that need an ID before hitting the DB, generate in a factory or service constructor (new Order(Ulid::generate(), ...)) rather than in an observer, so tests can inject deterministic IDs.
Cursor pagination with ULIDs. Use cursorPaginate() and pass the generated ID as the cursor — since ULIDs are time-ordered, ordering by the ID column approximates ordering by creation time and avoids offset drift.
symfony/uid (MIT), which is what Str::ulid() wraps.Str::ulid() and the HasUlids trait exist and are backed by symfony/uid. Only pull this package in if you specifically need a lighter dependency or a feature it exposes (e.g. custom time/random providers), not just “to get ULIDs”.I, L, O, U. If you accept mixed-case input, uppercase it before lookup — WHERE id = '01j…' will not match '01J…' on a default ci collation on MySQL but will on PostgreSQL/bc collation. That mismatch causes “not found after refresh” bugs.symfony/uid’s Ulid with a monotonic factory or a different primitive.char(26) (or binary_sqlite/binary(16) if you decode) rather than varchar(255); on MySQL, varchar + utf8mb4 indexes are wider than necessary and the char(26) column aligns naturally with the sortability guarantee. Always index it.I present, 25/27 chars), it almost always means the code is passing a UUID or a random string somewhere — grep for the model factory and migration defaults. A quick sanity check is strlen($id) === 26 and the regex above.fromTimestamp() / fromDateTime() variant on the generator. Otherwise, decode the timestamp manually and note the library gives you no built-in parser.composer.json to avoid surprise API breaks.How can I help you explore Laravel packages today?