brokeyourbike/uid-keys
Laravel package that swaps auto-incrementing integer primary keys for unique UID keys on your Eloquent models. Add the trait and new records get collision-resistant string IDs automatically—ideal for APIs, distributed systems, and hiding sequential IDs.
This package is a thin convenience layer: it gives an Eloquent model string primary keys generated as UUIDs or ULIDs, without you hand-rolling boot() hooks on every model. It is not an ORM replacement, not a migration generator, and not a UUID/ULID library — it wraps Str::uuid() / Str::ulid() (or ramsey/uuid / Symfony's Ulid) behind a trait and a config file.
Minimal steps:
composer require brokeyourbike/uid-keys
php artisan vendor:publish --tag="uid-keys-config" # verify the tag in the README
The service provider is auto-discovered. Then opt a model in:
use Brokeyourbike\UidKeys\HasUidKeys; // confirm exact FQCN against the README first
class Post extends Model
{
use HasUidKeys;
}
and declare a matching column type in the migration:
Schema::create('posts', function (Blueprint $table) {
$table->ulid('id')->primary(); // or $table->uuid('id')->primary()
$table->string('title');
$table->timestamps();
});
First use case worth trying: create a table you would otherwise expose as auto-increment, migrate it, then run Post::create(['title' => 'x'])->id in tinker. You should get a 26-character ULID (or 36-character UUID) with no extra code. That single loop — trait + migration column + tinker — tells you whether the package's defaults match your schema conventions before you roll it across models.
Where to look first: the config file (UUID vs ULID default, and any column-type mapping), then the trait source. The whole package is small enough to read in five minutes, and you should — with zero stars and zero dependents, the source is the documentation.
Per-model opt-in, not global. The idiomatic use is to apply the trait only to models whose IDs are public-facing (users, posts, orders) and leave internal tables on bigIncrements. Blanket-applying string keys to every table costs you index size and join speed for no benefit on tables nothing external references.
Pick the type deliberately. UUIDv4 is opaque and non-sortable — good for IDs you never order by. ULID is lexicographically time-ordered — good for feeds, cursor pagination, and tables where you would otherwise orderBy('created_at'). If the config lets you set the default, set it once to match your dominant use case and override on the exceptions.
Relationships must agree on storage. The single most common integration bug:
// parent
$table->ulid('id')->primary();
// child — must be exactly the same type and length
$table->foreignUlid('post_id')->constrained();
// NOT $table->foreignUuid(...), NOT $table->string('post_id', 36)
Same for pivot tables: foreignUlid('user_id') and foreignUlid('role_id'), both ->index().
Factories. Don't set the ID in the factory definition — the trait generates it on creating. When you do need determinism for assertions:
$post = Post::factory()->create(['id' => (string) Str::ulid()]);
Confirm the trait only fills the key when it's empty; if it unconditionally overwrites, explicit-ID imports and fixtures break, and you'll need forceFill() after save or a separate non-trait path.
Route model binding needs no changes. Route::get('/posts/{post}') resolves by the string key as-is because $keyType = 'string' and $incrementing = false. Just don't reintroduce getRouteKeyName() returning something else by copy-paste from an old model.
Validation and API shape. IDs are JSON strings. Use Laravel's ulid / uuid validation rules rather than integer. Anything consuming your API that assumes a numeric id — mobile clients, generated SDKs — is a breaking change; plan for it.
Ordering by ULID. orderBy('id') is a cheap stand-in for orderBy('created_at') on single-writer tables. It is not a strict guarantee across multiple app servers creating rows in the same millisecond, so keep created_at as the tiebreaker if ordering is load-bearing:
->orderBy('id')->orderBy('created_at')
Cursor pagination. cursorPaginate() works well with ULIDs precisely because they're monotonic; this is the pattern where the ULID choice pays for itself.
Backfilling an existing table. The package does not migrate anything. The safe path is additive: add a nullable ulid column, backfill in chunks, add a unique index, switch reads, then drop the old primary key in a later deploy. Doing it in one migration on a large table will lock it.
char(36) UUID column "works" until something compares or validates it as a UUID. In MySQL strict mode a UUID written into char(26) errors; in non-strict mode it truncates and you get duplicate-key errors that look like generator bugs. Check SHOW CREATE TABLE before debugging anything else.boot hook. If your model defines its own boot() or bootHasUidKeys(), you can silently shadow the package's creating listener and get null IDs. Symptom: save() succeeds, id is null or 0 after refresh. Fix: call parent::boot() and rename your hook.$incrementing / $keyType conflicts. The trait sets both. Any model property or another trait overriding them produces SQLSTATE[23000] on insert or a cast to int on retrieval. Grep your traits before adding this one to a heavily-traited model.(int) $model->id becomes 0 and 1 silently. Audit exports, CSV writers, third-party packages with numeric-key assumptions (some admin panels, audit log packages, legacy queue payloads).php artisan config:cache until you re-cache. This catches people in CI where the cache is warmed once.id is not fillable, so Model::create(['id' => ...]) drops it. For seeders and imports use forceFill() or add id to $fillable on the specific model that needs explicit keys.binary(16) storage, migrations must use the matching column and models need a cast. Mixing binary storage with a char(36) column is a guaranteed silent corruption. Stick to the char-based default unless you've measured the index size problem.char(36) primary key plus three foreign keys is roughly 4–6× the index footprint of bigint. On high-row-count tables, benchmark before committing; on small reference tables, don't care.app/Concerns/ is a 20-minute job. Pin the version in composer.json and avoid depending on undocumented config keys.generateUid()), override it in your model to control the source — useful for injecting a deterministic generator in tests or a UUIDv7 implementation. If it doesn't, wrap it: bind your own generator and have the trait call through a service, so swapping later isn't a rewrite.RefreshDatabase plus assertDatabaseHas('posts', ['id' => $post->id]) is all you need; there's no special test helper. Assert the format once (Str::isUlid($post->id)) in a model test so a config drift or a dependency bump that changes the generator fails loudly rather than at the database.How can I help you explore Laravel packages today?