Reader Stacks

Generating URL Slugs in Laravel: Basic and Reusable Patterns

Str::slug() handles the text transformation — the actual hard part of a real slug generator is checking for and resolving collisions, which is exactly where a reusable trait pays off.

Generating URL Slugs in Laravel: Basic and Reusable Patterns

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.