khalyomede/laravel-eloquent-uuid-slug
A Laravel package that adds UUID slug support to Eloquent models, automatically generating unique, URL-friendly identifiers for your records so you can reference them in routes and links instead of exposing numeric IDs.
1. Install and read the source first. With ~23 stars and zero dependents, you are the QA team. The package is small enough to audit in one sitting:
composer require khalyomede/laravel-eloquent-uuid-slug
ls vendor/khalyomede/laravel-eloquent-uuid-slug/src/
Open the trait before writing any application code. Confirm three things: the exact trait namespace, the column name it writes to (slug, uuid, or uuid_slug), and whether it registers a creating/saving model event or expects you to call a method manually. Everything in this assessment assumes the conventional shape — verify against the source, because there is no meaningful third-party documentation trail for a package this size.
2. Add the column. If the package ships a migration, publish it; otherwise write your own. On a fresh table put it next to the primary key:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->uuid('slug')->unique(); // match the package's column name
$table->string('title');
$table->timestamps();
});
For existing tables use ->nullable()->unique() so the migration applies before you backfill.
3. Wire up the model.
use Khalyomede\EloquentUuidSlug\HasUuidSlug; // confirm namespace
class Post extends Model
{
use HasUuidSlug;
public function getRouteKeyName(): string
{
return 'slug';
}
}
4. First use case. The whole point is URLs like /posts/9f8c1e2a-... instead of /posts/42. Create one post via tinker, hit the route, and dump $post->slug to confirm generation actually fired. Do this before you touch a dozen models — if the package relies on an event that doesn't fire on insert() or firstOrCreate(), you want to know now.
Keep the auto-increment primary key; add the UUID as a second column. Do not swap the UUID in as the primary key. Integer PKs keep your indexes compact and your foreign keys cheap; the UUID is a public identifier, not an internal one. All your belongsTo/hasMany relations stay on id and need no changes at all.
Route model binding is the integration point. getRouteKeyName() is Laravel core, not package magic — the package only guarantees the column exists and is populated. This means controller code and Blade route() helpers work unchanged, and you can roll the package out one model at a time.
Hide numeric IDs at the serialization boundary. Route binding fixes URLs but not JSON. Use API Resources rather than $guarded/$hidden guesswork:
class PostResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->slug, // public identifier
'title' => $this->title,
'url' => route('posts.show', $this),
];
}
}
Never ship both id and slug in a public payload — consumers will hardcode the integer and you'll be stuck supporting it.
Backfill as a one-off command, not a migration. Existing rows won't have slugs, and generating them inside a migration couples you to the package forever:
Post::whereNull('slug')->lazyById(500)->each(function ($post) {
$post->slug = (string) Str::uuid();
$post->saveQuietly(); // skip observers during backfill
});
Then flip the column to NOT NULL in a follow-up migration. saveQuietly() matters: if you have observers that send mail or write audit rows, a backfill over 200k models will trigger all of them.
Factories need a story for the slug. Either let the trait generate it (cleanest) or add a state for deterministic tests:
Post::factory()->create(['slug' => 'fixed-slug-for-assertions']);
If the trait overwrites an explicitly passed slug, that's a bug you need to know about — test it once with a assertEquals.
Queue jobs and cache keys should use the slug too, or you'll leak IDs into logs, Horizon tags, and cache keys. cache()->remember("post:{$post->slug}", ...) keeps the identifier story consistent end to end.
UUIDv4 is not sortable. If the package generates v4 (random), do not order by or range-query the slug column. If you need time-ordered public IDs, you want ULIDs or v7 — either patch the generator via the package's extension point or generate in a model observer yourself.
Add the unique index or you have no collision guard. UUIDv4 collisions are practically impossible, but a missing index on a route-lookup column is a guaranteed full table scan on every request. ->unique() on the migration solves both.
Mass assignment is an identity vulnerability. If clients can POST a slug, they can register /users/admin or collide with an existing record to probe your error handling. Keep the slug out of $fillable, or guard it explicitly on create.
Do not treat the slug as a secret. It's an opaque identifier, not an authorization token. Every route still needs a policy or authorize() call — an unguessable URL is not access control.
404 vs 403 leaks existence. Route model binding returns 404 for missing and for records you filter out of the query. If you scope binding with ->scopeBindings() or a resolveRouteBinding override, you get consistent 404s for records the user can't see — good. Verify that's what happens, because the default is "find by slug, then authorize," which returns 403 and confirms the record exists.
Soft deletes. With SoftDeletes on the model, default binding excludes trashed rows, so a trashed post's URL 404s even though the slug is still taken in the DB. Decide whether you need withTrashed() binding, and remember the unique index still reserves that slug — you can't reissue it.
Slugs are permanent contracts. The moment a URL is shared, bookmarked, or indexed, changing the slug breaks it. Don't regenerate on title change. If the package regenerates on saving, that's a config or trait-behavior question you must resolve on day one.
Concurrency with firstOrCreate/updateOrCreate. These bypass model events in some code paths. Test that a firstOrCreated model actually has a slug before you rely on it in a find-or-create endpoint.
Check for config publishing. php artisan vendor:publish --provider="Khalyomede\..." — many small packages ship a config file for the column name or UUID version. If it exists, publish it and commit it rather than relying on vendor defaults that change on composer update.
Pin the version. Zero dependents and 23 stars means a composer update could pull an untested bump with no changelog. Use an exact or tilde constraint (~1.0) and read the diff when you bump.
Know your exit ramp. The blast radius is small: the trait only writes a column. If the package is abandoned, you replace use HasUuidSlug; with a five-line booted() hook in a base model or a shared trait, and nothing else in the app changes. That portability is the real argument for adopting it — and the reason you should not spread package-specific method calls across your controllers.
Extension points to look for in the source: a protected generation method you can override in your model, a configurable column name, and whether it dispatches an event you can hook. Those three determine how gracefully you can replace the package later.
How can I help you explore Laravel packages today?