Reader Stacks

Generating URL Slugs in Laravel: A Reusable Approach

Str::slug() handles the actual slugification — the real work is guaranteeing uniqueness, which is worth extracting into a reusable trait once more than one model needs it.

Generating URL Slugs in Laravel: A Reusable Approach

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.