Laravel's Str::slug() handles turning a title into a URL-friendly string, but the more important part of a real slug generator is guaranteeing uniqueness — the actual slugification is the easy 10% of the problem.
The basic slugify call
use Illuminate\Support\Str;
Str::slug('How to Build a REST API'); // "how-to-build-a-rest-api"
A one-off unique slug on model creation
protected static function booted()
{
static::creating(function ($post) {
$slug = Str::slug($post->title);
$count = static::where('slug', 'like', "{$slug}%")->count();
$post->slug = $count ? "{$slug}-{$count}" : $slug;
});
}
Extracting it into a reusable trait
Once a second model (categories, tags, products) also needs slug generation, duplicating this logic in each model's booted() method is exactly the kind of repetition worth pulling into a shared trait.
// app/Traits/HasSlug.php
namespace App\Traits;
use Illuminate\Support\Str;
trait HasSlug
{
protected static function bootHasSlug()
{
static::creating(function ($model) {
if (empty($model->slug)) {
$model->slug = static::generateUniqueSlug($model->{$model->slugSourceColumn()});
}
});
}
protected function slugSourceColumn(): string
{
return 'title';
}
protected static function generateUniqueSlug(string $source): string
{
$slug = Str::slug($source);
$original = $slug;
$count = 1;
while (static::where('slug', $slug)->exists()) {
$slug = "{$original}-{$count}";
$count++;
}
return $slug;
}
}
class Post extends Model
{
use HasSlug;
}
class Category extends Model
{
use HasSlug;
protected function slugSourceColumn(): string
{
return 'name';
}
}
Laravel's trait boot convention (bootHasSlug, matching the trait's own name) runs automatically alongside the model's own boot() method, without needing to call it manually from each model.
Why the while-loop uniqueness check beats a simple count
Counting existing rows and appending that number (as in the first, simpler example) can produce a collision if a slug was previously deleted and reused, or under concurrent creation — checking exists() in a loop and incrementing until a genuinely free slug is found is more robust, at the cost of a few more queries in the rare collision case.
Regenerating a slug when the title changes
protected static function bootHasSlug()
{
static::creating(function ($model) {
$model->slug = static::generateUniqueSlug($model->{$model->slugSourceColumn()});
});
static::updating(function ($model) {
if ($model->isDirty($model->slugSourceColumn()) && ! $model->isDirty('slug')) {
$model->slug = static::generateUniqueSlug($model->{$model->slugSourceColumn()});
}
});
}
Checking ! $model->isDirty('slug') before regenerating lets a manually-set slug (someone editing it directly in an admin form) take precedence over automatic regeneration from a changed title.