Laravel's Str::slug() handles the actual text-to-slug transformation — the more interesting part of building a real slug generator is handling collisions when two different titles would otherwise produce the same slug, which is where a reusable trait pays off across multiple models.
The basic transformation
use Illuminate\Support\Str;
$slug = Str::slug('How to Build a REST API in Laravel!'); // "how-to-build-a-rest-api-in-laravel"
Auto-generating a slug when a model is created
class Post extends Model
{
protected static function booted(): void
{
static::creating(function (Post $post) {
$post->slug = Str::slug($post->title);
});
}
}
The collision problem
// Two posts both titled "Laravel Tips" would both generate slug "laravel-tips"
// The second insert either fails (if slug is unique) or silently duplicates (if not)
Resolving collisions with a numeric suffix
protected static function booted(): void
{
static::creating(function (Post $post) {
$baseSlug = Str::slug($post->title);
$slug = $baseSlug;
$count = 1;
while (static::where('slug', $slug)->exists()) {
$slug = "{$baseSlug}-{$count}";
$count++;
}
$post->slug = $slug;
});
}
This loop checks each candidate slug against existing records and appends an incrementing number only when a genuine collision occurs — "laravel-tips", then "laravel-tips-1", then "laravel-tips-2", and so on, only as far as actually needed.
Extracting this into a reusable trait
trait HasSlug
{
protected static function bootHasSlug(): void
{
static::creating(function ($model) {
$sourceField = $model->slugSourceField ?? 'title';
$baseSlug = Str::slug($model->{$sourceField});
$slug = $baseSlug;
$count = 1;
while (static::where('slug', $slug)->exists()) {
$slug = "{$baseSlug}-{$count}";
$count++;
}
$model->slug = $slug;
});
}
}
class Post extends Model
{
use HasSlug;
}
class Product extends Model
{
use HasSlug;
protected string $slugSourceField = 'name';
}
Extracting the collision-checking logic into a trait, with a configurable source field, lets any model needing slugs (posts, products, categories) reuse the exact same generation and collision-handling logic without duplicating it — following the standard Laravel trait-based reuse pattern.
Regenerating the slug on update (or deliberately not)
static::updating(function ($model) {
if ($model->isDirty('title') && ! $model->isDirty('slug')) {
$model->slug = static::generateUniqueSlug($model->title);
}
});
Whether a slug should regenerate when its source title changes is a genuine product decision, not just a technical one — regenerating breaks any previously shared or bookmarked URL, while never regenerating means an old, since-corrected title stays baked into the URL indefinitely; many sites deliberately choose the latter to avoid breaking links.
Excluding the current record when updating (avoiding false self-collision)
while (static::where('slug', $slug)->where('id', '!=', $model->id)->exists()) {
$slug = "{$baseSlug}-{$count}";
$count++;
}
Without excluding the current record's own ID from the collision check, updating a record without actually changing its slug-source field would incorrectly detect its own existing slug as a "collision" against itself.