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

Eloquent Sluggable Laravel Package

kduma/eloquent-sluggable

Generate unique, customizable slugs for Laravel Eloquent models. Automatically turn titles or other fields into URL-friendly strings, with configurable uniqueness, soft-delete handling, and per-model options. Simple to install, easy to integrate, and ideal for SEO-friendly URLs in any Laravel applic...

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and pin. composer require kduma/eloquent-sluggable — with 2 stars and 0 dependents this is a small fork, so pin an exact-ish constraint (^1.0) and commit composer.lock. Before writing any code, look at two things in vendor/kduma/eloquent-sluggable/: the src/ directory (for the trait's namespace) and composer.json (for the Laravel versions it declares). Forks of cviebrock/eloquent-sluggable frequently keep the upstream namespace, so the trait may still be Cviebrock\EloquentSluggable\Sluggable. Confirm before mass-editing imports.

2. Add the column. Slugs are plain string columns — the package never touches your schema:

Schema::table('posts', function (Blueprint $table) {
    $table->string('slug')->nullable()->index();
});

Start with an index, not ->unique() — see the soft-delete gotcha below. Add the unique constraint once you've decided how trashed rows behave.

3. Wire the model. This is the entire first use case — a blog post whose URL is derived from its title:

use Cviebrock\EloquentSluggable\Sluggable; // verify against vendor/src

class Post extends Model
{
    use Sluggable;

    public function sluggable(): array
    {
        return [
            'slug' => ['source' => 'title'],
        ];
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}
$post = Post::create(['title' => 'Hello, World!']);
$post->slug; // "hello-world"

That's it — Post::find('hello-world') and route('posts.show', $post) now work. Everything else in this package is configuration of that one saving hook.

4. Publish the config only if you need it. php artisan vendor:publish --provider="...ServiceProvider" gives you config/sluggable.php with per-model defaults (separator, maxLength, unique, onUpdate, includeTrashed, reserved, method). Per-model sluggable() keys override it, so most projects never publish.

Implementation Patterns

Per-field options beat global config. Everything the config file does can be expressed inline, which keeps the behaviour next to the model that depends on it:

public function sluggable(): array
{
    return [
        'slug' => [
            'source'    => ['title', 'subtitle'], // joined with the separator
            'separator' => '-',
            'maxLength' => 80,
            'onUpdate'  => false,   // default: slug is frozen after creation
            'unique'    => true,    // collisions get -1, -2, ...
        ],
    ];
}

onUpdate => false is the SEO-correct default: renaming a published post does not break inbound links. Flip it to true only for drafts or internal-only entities, and pair it with a redirect table (below).

Return multiple keys to maintain multiple slugs. The method returns a map, so a second derived column costs one array entry:

return [
    'slug'     => ['source' => 'title'],
    'slug_alt' => ['source' => 'title_en', 'onUpdate' => true],
];

This is the pragmatic path to per-locale URLs without waiting for first-class translation support: give each locale its own column and resolve getRouteKeyName() from the current locale.

Custom generation via method. Supply a callable when Str::slug isn't enough — CJK titles, transliteration, or preserving dots:

'slug' => [
    'source' => 'title',
    'method' => fn (string $string, string $separator) =>
        Str::slug(Str::transliterate($string), $separator) ?: 'post',
],

Always return a non-empty fallback. A title consisting only of characters Str::slug strips yields an empty string, and you'll chase a NOT NULL/unique violation later.

Generate slugs without saving. Useful for previews, imports, and backfill scripts — call the slug service directly:

use Cviebrock\EloquentSluggable\Services\SlugService;

$slug = SlugService::createSlug(Post::class, 'slug', 'My Draft Title');

This runs the same uniqueness resolution as the observer, so it will return my-draft-title-1 if the first is taken. Signature varies by fork version — check src/Services/SlugService.php.

Scope helpers for lookup. If the model doesn't define getRouteKeyName(), add the companion trait for findBySlug() / findBySlugOrFail() / whereSlug(). These are thin wrappers, but they document intent and keep where('slug', $x) from spreading through controllers.

Keep a slug history table. Since onUpdate freezes slugs, a rename should either 404 or 301. The clean pattern is an observer on the model's updated event that records (old_slug, new_slug) when isDirty('slug'), plus a catch-all route that looks up the old value and redirect()->to($new, 301). This is the single highest-value addition to a sluggable model in production.

Bulk imports bypass everything. Post::insert([...]), DB::table('posts')->insert(), and ->update() on a query builder never fire Eloquent events, so no slugs. For seeders and CSV imports, either loop with ->create() (slow, one uniqueness query per row) or precompute with SlugService::createSlug() and include the slug in the insert array. For imports over a few thousand rows, the latter is the only workable option.

Expose it in API resources. Add 'slug' => $this->slug to the resource and use Route::get('/posts/{post:slug}') for explicit binding — clearer than a getRouteKeyName() override that silently affects every route in the app.

Gotchas and Tips

Soft deletes vs. a DB unique index. If includeTrashed is false, the uniqueness check ignores trashed rows, so a new post can reuse a deleted post's slug — but the trashed row is still in the table and will blow up your ->unique() constraint at insert time. Pick one: drop the DB constraint, set includeTrashed => true, or make the index composite on (slug, deleted_at).

Unique suffix collisions are not errors. unique => true appends -1, -2, … silently. A seeder that creates ten posts titled "Draft" produces draft, draft-1, … draft-9, and re-running it produces a different set. Assert on slugs in tests rather than eyeballing them.

Reserved words. Slugs like create, edit, new, or anything matching a sibling route segment will shadow your resource routes. Populate the reserved config array so those get a suffix instead. This bites hardest on /posts/{post} vs /posts/create.

Each save costs a query. The observer runs a SELECT for uniqueness on every saving. On a form that saves the model three times per request, that's three queries. Since the slug is frozen after creation by default, this is usually the only query on update — but if you see slug queries in a hot path, check whether something is calling save() in a loop.

maxLength causes truncation collisions. Two long, similar titles truncate to the same slug and the second silently gets -1. Set maxLength generously (120–150) rather than trying to fit a tight URL budget.

Don't hand-assign slugs in creating listeners. The service runs on saving, so a manually set slug may be overwritten or may survive depending on whether the field is already populated in your version. If you need manual control, set the value in saving with a lower priority than the package, or set onUpdate/unique and accept the library's resolution. Test the exact ordering before relying on it.

Non-Latin input silently degrades. Str::slug('你好') returns ''. If your content isn't Latin-script, the method closure with Str::transliterate() (Laravel 9+) or a dedicated transliterator is mandatory, not optional.

Low-adoption fork = you are the QA. With 0 dependents, nobody else has hit your edge case. Before an upgrade, diff vendor/kduma/eloquent-sluggable/src/Services/SlugService.php against the previous version, and keep a handful of tests covering: collision suffixing, frozen-on-update, reserved words, empty source, and soft-deleted duplicates. Those five assertions catch essentially every regression a fork can introduce.

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.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log