Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Sluggable Laravel Package

spatie/laravel-sluggable

Generate unique slugs for Eloquent models on create or update via a Sluggable attribute or HasSlug trait. Includes collision suffixes, translatable slugs, and self-healing URLs with 308 redirects.

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and add a slug column. Keep it nullable and index it — the package writes the slug during the saving event, but a nullable column keeps existing rows and manual imports from blowing up.

composer require spatie/laravel-sluggable
php artisan make:migration add_slug_to_posts_table
Schema::table('posts', function (Blueprint $table) {
    $table->string('slug')->nullable()->unique()->after('title');
});

Two entry points, pick by complexity. For a plain "title → slug" model, use the attribute — no trait, no boilerplate:

use Spatie\Sluggable\Attributes\Sluggable;

#[Sluggable(from: 'title', to: 'slug')]
class Post extends Model {}

The moment you need a closure (custom source, scoped uniqueness, conditional skip, custom suffix), drop the attribute and use the trait with getSlugOptions():

use Spatie\Sluggable\HasSlug;
use Spatie\Sluggable\SlugOptions;

class Post extends Model
{
    use HasSlug;

    public function getSlugOptions(): SlugOptions
    {
        return SlugOptions::create()
            ->generateSlugsFrom('title')
            ->saveSlugsTo('slug');
    }
}

First use case worth wiring end-to-end: a Post resource with route model binding. Create the model, set getRouteKeyName() to slug, and let route('posts.show', $post) produce /posts/my-first-post. If you also want rename-safe URLs, add selfHealing: true to the attribute and use HasSlug (mandatory in that case — the trait overrides getRouteKeyName() and resolveRouteBinding(), the attribute alone does not), and you get /posts/my-first-post-5 with a 308 redirect from stale slugs.

Where to look first: the docs site (linked from the README) rather than the README itself — the README is a highlight reel, and the trait's full option surface (preventOverwrite, extraScope, usingSuffixGenerator, skipGenerateWhen) only lives in the docs. Check UPGRADING.md before bumping to v4.

Implementation Patterns

Multi-field sources and formatting. generateSlugsFrom() accepts an array or a closure. Most real models need more than one field:

return SlugOptions::create()
    ->generateSlugsFrom(['first_name', 'last_name'])
    ->saveSlugsTo('slug')
    ->slugsSeparator('-')
    ->maximumLength(80);

Prefer a closure when you need to combine columns conditionally (e.g. append a year, fall back to a SKU). In v4, closure options are typed Closure — array callables and strings must be wrapped:

->generateSlugsFrom(fn (Post $post) => $post->title . ' ' . $post->published_at?->year)

Scoped uniqueness for multi-tenant / hierarchical data. By default uniqueness is global. If two tenants can both own a "pricing" page, scope it:

->extraScope(fn (Builder $query) => $query->where('tenant_id', $this->tenant_id))

The scope is applied to the collision-check query, so the -1, -2 suffixing happens within the tenant. This is the single most under-used option; without it your second tenant gets pricing-1.

Stable slugs vs. editable slugs. Decide this deliberately per model:

  • Editable slug (default): the slug regenerates from the source on every save, so renaming a post changes its URL and breaks inbound links.
  • Stable slug: add ->preventOverwrite(). Once written, the slug sticks even if the title changes. Good for permalinks, product SKUs, anything shared externally.
  • Self-healing: allow renames but never break links — combine selfHealing: true with the HasSlug trait. Old URLs 308 to the canonical one. Prefer this for content sites, preventOverwrite() for anything a third party might deep-link to with confidence.

Note that preventOverwrite and self-healing solve different problems: the former avoids the URL changing, the latter absorbs the change.

Custom suffixes. Default collision behaviour is -1, -2, -3. Override with usingSuffixGenerator() when you want randomness or a non-numeric scheme (e.g. dates for changelog entries, short hashes for unguessable URLs):

->usingSuffixGenerator(fn (string $slug, int $iteration) => $slug . '-' . Str::lower(Str::random(6)))

Conditional generation. skipGenerateWhen() lets you opt out per save — useful when an admin has manually curated a slug, or when the source field is empty and you want to fall back to the ID-based URL. In v4 the predicate is evaluated on every save (v3 evaluated it once), so a closure reading current model state behaves the way you'd expect.

Lookups. Use findBySlug() (or its alias) instead of hand-rolling where('slug', ...). It handles translatable slugs, fallback locales, and accepts an additional query callback for soft-delete or status scoping.

Translatable slugs. With spatie/laravel-translatable installed, swap HasSlug for HasTranslatableSlug and generate from a translated field. Uniqueness is then per-locale, and findBySlug() resolves against the active locale with a fallback chain.

Overriding the generator. If your slug rules are non-trivial and repeated across many models (transliteration, stopword removal, custom Str pipeline), publish config/sluggable.php and point the action at your own class rather than duplicating a closure everywhere. Same mechanism exists for the self-healing URL builder/parser.

Backfilling existing rows. Slugs are generated on save, so pre-existing records need a pass:

Post::query()->chunkById(200, function ($posts) {
    $posts->each(fn (Post $post) => $post->save());
});

Do this before adding a NOT NULL constraint or a unique index if the backfill could produce collisions across the whole table — the package's uniqueness check only looks at rows already committed.

Gotchas and Tips

Concurrent writes can still produce duplicates. Uniqueness is enforced by a SELECT before insert, not by the database. Two requests creating "hello-world" simultaneously can both see zero matches and both write the same slug. Keep the unique index on the column so you fail loudly, and wrap creation in a transaction with a retry on QueryException (or a firstOrCreate-style guard) for high-traffic endpoints.

Renaming silently breaks URLs — check the mode. The default regenerates the slug on every save. If a client reports dead links after an editor fixed a typo in a title, this is why. Either preventOverwrite() or self-healing, decided up front.

Custom separators and LIKE wildcards. Fixed in 4.0.3. On SQLite (and other drivers without a default LIKE escape), a separator like _ or a slug containing % used to make the variant lookup match nothing, so foo_bar_1 got issued twice. If you use a non-dash separator on SQLite, pin ^4.0.3.

v4 closure typing. generateSlugsFrom, extraScope, usingSuffixGenerator, and skipGenerateWhen are now Closure-typed. [$this, 'slugSource'], 'strtolower', and invokable class strings all break — wrap in fn (...) => ... or an arrow function calling your method.

No $slugOptions cache in v4. The trait no longer memoizes the options object. If you were overriding $slugOptions to inject behaviour, move that into generateSlugAction(). Also: getSlugOptions() is now called more often than you might assume — don't put expensive queries in it.

The Sluggable facade is now SelfHealing in v4. Grep for it before upgrading; the alias is gone.

Self-healing changes your route key shape. getRouteKeyName() now returns a composite key (hello-world-5). That means:

  • Route constraints like ->whereNumber('post') fail — the segment is not numeric.
  • Any hand-built URL that concatenates $post->slug alone will 404 or hit the redirect path unnecessarily. Always use route(..., $post).
  • External systems parsing your URLs must tolerate the -{id} suffix. This is a public API change, not just an internal one.

Event ordering matters. The slug is set in the saving event. A saving listener or an accessor/mutator registered after the package can overwrite it. If slugs come out wrong for one model only, check its observers and setSlugAttribute mutator before blaming the package.

saveQuietly() and withoutEvents() skip slug generation entirely — same for DB::table()->insert() and raw upserts. Useful for imports, but remember to backfill.

Empty source → empty slug. If title is blank and maximumLength is applied, you get an empty string (or just the suffix). Make the column nullable, add a skipGenerateWhen(fn () => blank($this->title)) guard, and consider falling back to a UUID or the model key in a creating hook.

Debugging checklist when a slug isn't generated: (1) is the trait present and getSlugOptions() implemented, or the attribute applied? (2) does the target column exist and is it fillable/guarded correctly? (3) is the model being saved via Eloquent (not a raw query)? (4) is another listener overwriting it? Model::getEventDispatcher() and a quick saving dump settles it fastest.

Test at the model level, not the HTTP level. Assert $post->slug after create(), assert collision behaviour by creating two models with the same title, and assert the 308 for stale self-healing URLs with $this->get('/posts/old-slug-'.$post->id)->assertRedirect(...). Use RefreshDatabase — the uniqueness check is a real query, so in-memory SQLite without migrations will mislead you.

Laravel Boost integration. If your team uses Boost, the bundled skill teaches AI assistants the attribute/trait split automatically once both packages are installed — worth knowing so generated scaffolding matches your project's convention instead of hand-rolling Str::slug() in a controller.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
frankyso/ipaymu
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core