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

Sluggable Laravel Package

marshmallow/sluggable

A lightweight Laravel package that automatically generates clean, unique slugs for your Eloquent models. Easily customize the source field, separator, and update behavior, with built-in duplicate handling to keep URLs SEO-friendly and readable. Ideal for blogs, products, and any model needing stable...

View on GitHub
Deep Wiki
Context7

Getting Started

Install and orient yourself

composer require marshmallow/sluggable

Version floor: v1.12.0 is the first release declaring Laravel 13 support. If you're on Laravel 13, pin ^1.12; anything below that won't have the constraint and Composer will refuse to install it against a Laravel 13 skeleton. There are no breaking changes in v1.12.0 — it's the same API as v1.11.x, so code written against the old version carries over untouched.

Before writing any code, read the README and then skim src/ — with a package this small the source is the documentation. Look specifically for:

  1. The trait name — almost certainly HasSlug (possibly Sluggable). Confirm the exact namespace, e.g. Marshmallow\Sluggable\HasSlug.
  2. The config file — check whether a config is published (php artisan vendor:publish --tag=sluggable-config) or whether configuration lives entirely in trait properties on the model. Many small slug packages skip the config file entirely and use overridable methods/properties.
  3. The events it hooks into — saving/creating vs. an explicit generateSlug() call. This determines everything about your integration.

Minimal working example

Add the column first:

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

Then attach the trait:

use Marshmallow\Sluggable\HasSlug;

class Post extends Model
{
    use HasSlug;

    protected $slugSource = 'title';   // verify exact property name in src/
}

First use case: route model binding. This is the payoff. Add getRouteKeyName() to the model so /posts/my-first-post resolves automatically:

public function getRouteKeyName(): string
{
    return 'slug';
}

Then Route::get('/posts/{post}', ...) "just works", and route('posts.show', $post) outputs the slug instead of the ID everywhere — links, sitemaps, redirect()->route(), etc. Test this in Tinker first ($post->save(); $post->slug;) before wiring controllers.

Implementation Patterns

Trait + explicit source per model. The common shape is: trait on the model, a property or overridable method declaring the source column(s). If the package supports multiple sources, pass an array (['title', 'subtitle']) — useful for disambiguating posts with identical titles.

Route model binding + fallback. Point getRouteKeyName() at slug, but keep numeric-ID access working for admin/legacy URLs. Easiest approach: a resolveRouteBinding() override that falls back to id when the segment is numeric:

public function resolveRouteBinding($value, $field = null)
{
    return $this->where('slug', $value)->first()
        ?? $this->where('id', $value)->first();
}

This avoids 404s on old bookmarks once you switch to slugs.

Only regenerate when the source changes. The single most important pattern. In saving, the trait may regenerate on every save — that silently churns URLs whenever anyone edits an unrelated field. Guard it:

protected static function booted()
{
    static::updating(function ($model) {
        if (! $model->isDirty($model->slugSource)) {
            $model->slug = $model->getOriginal('slug'); // pin the old slug
        }
    });
}

Run this after the trait's own hook is registered (it will be, since traits boot first), or use saved + manual regenerate.

Slugs on parent/child hierarchies. For categories or nested pages, either include the parent slug in the source ($this->parent->slug . ' ' . $this->title) or store a path column alongside slug. Prefer the flat path column — it makes route binding a single indexed lookup instead of a recursive query per request.

API resources. Because the slug is just a model column, add it to your JsonResource explicitly: 'slug' => $this->slug, 'url' => route('posts.show', $this). Don't rely on parent::toArray() if $hidden excludes it — check and mirror.

Seeders and imports. Model events fire during seeding (good), but DB::table()->insert() and Post::insert() bypass them entirely (bad — you'll get null slugs). If you bulk-import, either use Post::create() in a loop, or generate slugs yourself in the import step using the package's generator method (usually exposed statically or as generateSlug()).

Backfilling. For an existing table, write a one-off command that iterates in chunks and calls $model->save(), or a raw update with your own slugifier. Set the column nullable first, backfill, then add the unique index and consider NOT NULL (via a follow-up migration) once every row has one.

Gotchas and Tips

Uniqueness is a race condition, not a guarantee. The package likely does "check if slug exists, append -2". Two concurrent requests creating "Hello World" can both pass the check. The unique() index on the column will then throw a QueryException — catch it and retry with a fresh suffix rather than letting it 500. On MySQL, do the suffixing inside a transaction with a locking read if collisions matter (e.g. public-facing pages).

Slug column length vs. index length. string('slug') is VARCHAR(255). Under utf8mb4 the index limit is fine, but if your source is long (a full title, a paragraph) the slug gets truncated mid-word or the insert fails. Truncate the source before slugifying to ~180 chars, and leave headroom for -1024 suffixes.

Trailing numbers on uniqueness look like dates/times. my-post-2024 is confusing when it's actually the 2024th duplicate. Some packages use -2, -3; others use a random hash. Know which, and if you need stable public IDs don't use the counter — it changes if an earlier duplicate is deleted.

Reserved words and collisions with literal routes. A post titled "admin" becomes /posts/admin — fine under /posts, broken if slugs live at the site root (/admin collides with your admin panel route). Maintain a blocklist (or filter at save) whenever slugs are top-level.

Unicode and transliteration. Does the package use Str::slug() (transliterates café → cafe) or something more naive (strips non-ASCII → caf)? Check with a quick Tinker test using accents, emoji, CJK, and &//. Non-transliterating slugs will 404 ambiguously or produce empty strings.

Soft deletes and uniqueness. A unique() index counts soft-deleted rows. Delete a post, recreate one with the same title → unique constraint violation if the package's uniqueness check doesn't exclude trashed rows. Either use unique(['slug', 'deleted_at']) (with a nullable trick depending on DB), or the package's withTrashed() handling if it has one.

Changing the source column name. If the trait exposes a getSlugSource()-style method, overriding it on the model is the cleaner extension point than a property — properties can't be computed, and you'll want computed values for hierarchical slugs. Check src/ for which one exists before designing around a property.

Debugging checklist, in order: (1) Is the trait actually imported — does class_uses_recursive(Post::class) include it? (2) Is the source field dirty at save time — is it in $fillable, and are you using $model->fill() vs forceFill()? (3) Did you save after assigning the source — order of create([...]) matters if the package is on saving. (4) Is the slug column in the model's $hidden, making it look null in JSON? (5) Are you calling update() on a query builder (fires events) vs. ->toBase()->update() (doesn't)?

Don't regenerate on every deployment/seeder re-run. firstOrCreate() is safe (events fire but only on create). updateOrCreate() on every deploy will re-slug if the source is unchanged but the diff logic is missing — audit your seeders for updateOrCreate on slugged models.

Treat the slug as immutable once published. Whatever the package defaults to, add explicit protection: once a resource is "published"/has traffic, a slug change is a broken link. Either generate a 301 redirect record on change (slug_history table keyed by old slug) or reject slug edits in your form request. This is a policy decision the package won't make for you, and it's the difference between "slugs are nice" and "slugs broke our SEO."

Assess the maintenance signal, not just the changelog. v1.12.0 is a maintenance release: a Laravel 13 compatibility PR plus a Dependabot bump of actions/checkout. That's a positive sign — someone is keeping the framework constraint current — but it tells you nothing about the slug logic itself, which hasn't changed. The package still has effectively no dependents and a single active contributor, so keep it pinned (^1.12) and re-check the constraint on every Laravel major upgrade rather than assuming it keeps up. If it turns out to be a thin wrapper around Str::slug() + uniqueness, consider inlining ~30 lines into a local trait rather than carrying the dependency — you'd control the Laravel 13 upgrade timing yourself.

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.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource