Laravel Sluggable
Laravel Sluggable generates URL-friendly slugs for your Eloquent models. Add the Sluggable trait and one $sluggable property, and the slug column fills itself when a record is created. When you need more, a sluggable() method can fill any number of slug columns, each from its own attribute, relation or computed value. The text itself is slugified by PHP Slugify, so Unicode, transliteration and language rules come from one place.
One-Line Setup
fills the column on create, using your global config.
Unlimited Slug Columns
Return pairs from to fill , , and more.
Callable Sources
Use to build a slug from a relation, an accessor or a computed value.
Unique Per Column
Collisions get , , ... suffixes, found with one query and kept within .
Stable Links
Manual slugs are never overwritten, and existing slugs only change when you enable .
Unicode & ASCII
Arabic and other scripts stay readable by default. Switch on to transliterate.
Keep the unique index that $table->sluggable() creates. The package picks a free slug before saving, but only the database can guarantee uniqueness when two requests save at the same time.
Installation
Install the package with Composer. Laravel discovers the service provider automatically.
Requirements
- PHP 8.0
- Laravel 6.20 or newer within 6.x
pharaonic/php-slugify8.0.3+ within 8.0.x (installed automatically)
Composer Installation
composer require pharaonic/laravel-sluggablePublish Configuration
Publishing the config is optional. Without it, the package uses its defaults (separator -, unique slugs, generated on create only).
php artisan vendor:publish --tag=sluggable-configThis creates config/pharaonic/sluggable.php.
--tag=laravel-sluggable, --tag=pharaonic and --tag=pharaonic-config publish the same file.
Add a Slug Column
Add the column with the sluggable migration macro:
1Schema::create('posts', function (Blueprint $table) {2 $table->bigIncrements('id');3 $table->string('title');4 $table->sluggable(); // "slug": string, nullable, unique5 $table->timestamps();6});You're all set! Add the Sluggable trait to a model and create a record to see its slug.
Configuration
The config lives in config/pharaonic/sluggable.php once published, and is read from pharaonic.sluggable. These values apply to every slug unless a per-slug option overrides them.
1return [ 2 'separator' => '-', 3 'unique' => true, 4 'on_create' => true, 5 'on_update' => false, 6 'include_trashed' => false, 7 'max_length' => 255, 8 'ascii_only' => false, 9 'ascii_lang' => env('APP_LOCALE', 'en'),10];Options
| Key | Default | Description |
|---|---|---|
separator | - | Joins the words of the slug and the unique suffix. |
unique | true | Append -2, -3, ... when the slug is already taken in its column. |
on_create | true | Generate slugs when a model is created (only for empty slug columns). |
on_update | false | Regenerate a slug when its source changes on update. |
include_trashed | false | Also check soft-deleted rows when looking for a unique slug. |
max_length | 255 | Maximum slug length in characters, suffix included. null means no limit. |
ascii_only | false | Transliterate slugs to ASCII (Crème brûlée → creme-brulee). |
ascii_lang | APP_LOCALE or en | Language passed to PHP Slugify for language-specific rules. |
Resolution Order
Each option is resolved per slug:
per-slug option → config/pharaonic/sluggable.php → package defaultThe $sluggable property has no per-slug options, so it always uses the config.
With ascii_only set to false, slugs keep non-Latin letters: مرحبا بالعالم becomes مرحبا-بالعالم. Browsers display these URLs as written and encode them automatically.
Basic Usage
Add the Sluggable trait to your model and name the source attribute in the $sluggable property. The slug is written to the slug column.
1namespace App; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Sluggable\Sluggable; 5 6class Post extends Model 7{ 8 use Sluggable; 9 10 protected $fillable = ['title'];11 12 protected $sluggable = 'title';13}Create records as usual. The slug is generated just before the insert:
1$post = Post::create(['title' => 'Hello World']);2 3$post->slug; // "hello-world"4 5Post::create(['title' => 'Hello World'])->slug; // "hello-world-2"Finding by Slug
The trait adds a whereSlug scope and two finders on the slug column:
1Post::findBySlug('hello-world'); // Post or null2Post::findBySlugOrFail('hello-world'); // Post or ModelNotFoundException3Post::whereSlug('hello-world')->first();To resolve route model binding by slug, return the column from getRouteKeyName():
1public function getRouteKeyName()2{3 return 'slug';4}1Route::get('/posts/{post}', function (App\Post $post) {2 return $post; // resolved by slug3});Slug With Key
The slug_with_key attribute prefixes the slug with the model key, which is handy for URLs that must stay unique even if slugs repeat:
1$post->slug_with_key; // "1-hello-world"For models whose class name ends with Translation (such as PostTranslation), the key of the parent model (post_id) is used instead.
Mass Assignment
When your model uses $fillable, the slug columns are added to it automatically, so you can pass a slug to create():
1Post::create(['title' => 'Hello World', 'slug' => 'custom-url'])->slug; // "custom-url"The $sluggable property always reads a direct attribute of the model. 'category.name' is not treated as a relation path. Use a callable source for that.
Multiple Slugs
Define a sluggable() method when a model needs more than one slug, or a slug in a column other than slug. Each array key is the target column and each value is its source.
1use Illuminate\Database\Eloquent\Model; 2use Pharaonic\Laravel\Sluggable\Sluggable; 3 4class Product extends Model 5{ 6 use Sluggable; 7 8 public function sluggable(): array 9 {10 return [11 'slug' => 'name',12 'seo_slug' => 'seo_title',13 'share_slug' => 'share_title',14 ];15 }16}Add one column per slug in the migration:
1$table->sluggable('slug');2$table->sluggable('seo_slug');3$table->sluggable('share_slug');There is no limit on the number of slugs. Each one is generated on its own and checked for uniqueness against its own column only, so slug and seo_slug may both be hello on the same row.
Method or Property
When both $sluggable and sluggable() are defined, the sluggable() method always takes precedence. The two configurations are never merged.
1protected $sluggable = 'title'; // ignored2 3public function sluggable(): array4{5 return ['seo_slug' => 'seo_title']; // only seo_slug is generated6}One Source per Slug
Each slug has exactly one source. To combine several values into one slug, return them from a callable source:
1'slug' => fn ($model) => $model->brand.' '.$model->name,Callable Sources
A source can be a callable instead of an attribute name. It receives the model and returns the string to slugify. Use it for relations, accessors and computed values.
1public function sluggable(): array2{3 return [4 'slug' => 'title',5 'category_slug' => fn ($model) => $model->category?->name ?? '',6 'parent_category_slug' => fn ($model) => $model->category?->parent?->name ?? '',7 ];8}1$post = Post::create(['title' => 'Hello', 'category_id' => $news->id]);2 3$post->category_slug; // "news"Computed Values
1'slug' => fn ($model) => $model->title.' '.$model->published_at->format('Y'),2// "Hello World" + 2024 → "hello-world-2024"How Sources Are Read
| Source | Read as |
|---|---|
'title' | $model->getAttribute('title') |
'category.name' | the attribute named category.name (not a relation path) |
fn ($model) => ... | the callable's return value |
A string source is always an attribute name, even when it matches a PHP function name such as 'date'.
Empty Results
When the source is empty, or slugifies to an empty string, the slug column is set to null. The column created by $table->sluggable() is nullable for this reason.
The package doesn't eager-load anything. Accessing $model->category inside the callable runs a query the first time. When creating many records in a loop, load the relation beforehand or pass the value through an attribute.
Per-Slug Options
Use an array with a source key to override the config for one slug. Simple and advanced definitions can be mixed in the same method.
1public function sluggable(): array 2{ 3 return [ 4 'slug' => [ 5 'source' => 'title', 6 'separator' => '-', 7 'unique' => true, 8 ], 9 10 'seo_slug' => [11 'source' => 'seo_title',12 'separator' => '_',13 'on_update' => true,14 ],15 16 'category_slug' => fn ($model) => $model->category?->name ?? '',17 ];18}Available Options
| Option | Type | Description |
|---|---|---|
source | string or callable | Attribute name, or a callable receiving the model and returning a string. Required. |
separator | string | Joins the words and the unique suffix. |
unique | bool | Append a suffix when the slug is taken in this column. |
on_update | bool | Regenerate this slug when its source changes. |
include_trashed | bool | Check soft-deleted rows for this slug's uniqueness. |
max_length | int or null | Maximum length of this slug, suffix included. |
Any option you leave out comes from config/pharaonic/sluggable.php, then from the package default.
Unique Slugs
With unique enabled (the default), a taken slug gets a numeric suffix:
1Post::create(['title' => 'Hello'])->slug; // "hello"2Post::create(['title' => 'Hello'])->slug; // "hello-2"3Post::create(['title' => 'Hello'])->slug; // "hello-3"How It Works
- Only the slug's own column is checked, so each slug column has its own sequence.
- One query fetches the slug and its
slug-Nvariants. The next number is the highest existing suffix plus one, so a gap such ashello-7continues withhello-8. - When updating, the current model is excluded, so a model never collides with itself.
- Slugs that only share a prefix are not collisions:
hello-worlddoesn't blockhello. %and_in a slug are escaped in theLIKEmatch.
Maximum Length
The suffix counts toward max_length. When it doesn't fit, the slug is shortened first:
1config(['pharaonic.sluggable.max_length' => 10]);2 3Post::create(['title' => 'abcdefghij'])->slug; // "abcdefghij"4Post::create(['title' => 'abcdefghij'])->slug; // "abcdefgh-2"Set max_length to null for no limit. Keep it at or below your column length (255 for $table->sluggable()).
Soft Deletes
For models using SoftDeletes, trashed rows are ignored by default, so a new record can reuse a deleted record's slug. Enable include_trashed to keep them reserved:
1'slug' => ['source' => 'title', 'include_trashed' => true],The unique index created by $table->sluggable() also covers trashed rows. If you keep include_trashed disabled on a soft-deleting model, saving a slug that a trashed row still holds will fail at the database. Enable include_trashed for those models.
Disabling Uniqueness
Set unique to false to store the slug as is. Use a plain $table->string() column (or ->index() instead of a unique index) in that case.
Updates & Manual Slugs
Slugs end up in links, so they don't change unless you ask for it.
On Create
A slug is generated only when its column is empty. A value you set yourself is kept:
1$post = new Post(['title' => 'Hello World']);2$post->slug = 'custom-url';3$post->save();4 5$post->slug; // "custom-url"Set on_create to false in the config to stop generating slugs on create.
On Update
on_update is false by default, so changing the source keeps the existing slug:
1$post = Post::create(['title' => 'Hello']);2$post->update(['title' => 'Goodbye']);3 4$post->slug; // "hello"Enable it globally in the config, or per slug:
1'slug' => ['source' => 'title', 'on_update' => true],1$post->update(['title' => 'Goodbye']);2 3$post->slug; // "goodbye"With on_update enabled:
- A string source is regenerated only when that attribute is dirty. A callable source is evaluated on every update.
- If the new slug is the same as the current one, or one of its suffixed variants (
hello-2forhello), the current slug is kept. - If you change the slug column yourself in the same save, your value wins.
Regenerating slugs changes your URLs. If old links are shared or indexed, keep on_update disabled or redirect the old slug yourself.
Migration Macro
The package registers a sluggable macro on the schema Blueprint. Its only argument is the target column, slug by default.
1Schema::create('posts', function (Blueprint $table) { 2 $table->bigIncrements('id'); 3 $table->string('title'); 4 5 $table->sluggable(); // slug 6 $table->sluggable('seo_slug'); 7 $table->sluggable('category_slug'); 8 9 $table->timestamps();10});Each call is the same as:
1$table->string($column)->nullable()->unique();The migration only owns the column. The source is always configured on the model.
Adding a Slug to an Existing Table
1Schema::table('posts', function (Blueprint $table) {2 $table->sluggable();3});Existing rows keep a null slug, since slugs are generated when a model is created. Fill them once with a small loop that sets each slug and saves.
Blade Directive
The @slug directive prints the slug of any value with PHP Slugify's slug() helper. It doesn't touch the database.
1<a href="/tags/@slug($tag->name)">{{ $tag->name }}</a>2{{-- "Laravel Tips" → /tags/laravel-tips --}}It accepts the same arguments as the helper: value, separator, ASCII only and language.
1@slug($title, '_')2@slug($title, '-', true, 'de')@slug echoes its result without e(). Slugs contain only letters, numbers and the separator, so this is safe with the default rules, but don't pass a separator that comes from user input.
API Reference
All methods live on the Pharaonic\Laravel\Sluggable\Sluggable trait.
Model Configuration
| Member | Description |
|---|---|
protected $sluggable = 'title'; | Generate slug from one direct attribute, using the config. |
public function sluggable(): array | Return column => source or column => [options] for any number of slugs. Takes precedence over $sluggable. |
Methods
| Method | Description | Returns |
|---|---|---|
Model::findBySlug(string $slug, array $columns = ['*']) | First model whose slug matches. | Model or null |
Model::findBySlugOrFail(string $slug, array $columns = ['*']) | Same, but throws when missing. | Model (throws ModelNotFoundException) |
Model::whereSlug(string $slug) | Query scope on the slug column. | Builder |
$model->slug_with_key | {key}-{slug}, using the parent key for *Translation models. | string |
findBySlug, findBySlugOrFail, whereSlug and slug_with_key use the slug column. For other slug columns, query them directly: Post::where('seo_slug', $slug)->first().
Slug Options
| Option | Default | Description |
|---|---|---|
source | — | Attribute name or callable. Per-slug only. |
separator | - | Word and suffix separator. |
unique | true | Append -2, -3, ... on collisions. |
on_create | true | Generate on create (config only). |
on_update | false | Regenerate when the source changes. |
include_trashed | false | Include soft-deleted rows in the uniqueness check. |
max_length | 255 | Maximum length, suffix included. null for no limit. |
ascii_only | false | Transliterate to ASCII (config only). |
ascii_lang | APP_LOCALE / en | Language hint for PHP Slugify (config only). |
Schema Macro
| Macro | Creates |
|---|---|
$table->sluggable(string $column = 'slug') | string($column)->nullable()->unique() |
Blade Directive
| Directive | Output |
|---|---|
@slug($value, $separator = '-', $asciiOnly = false, $language = 'en') | The slugified value |
Publish Tags
| Tag | Publishes |
|---|---|
sluggable-config, laravel-sluggable, pharaonic, pharaonic-config | config/pharaonic/sluggable.php |
Examples
1. Blog Posts With Slug URLs
A post gets a slug from its title, and the controller looks it up by slug.
1Schema::create('posts', function (Blueprint $table) {2 $table->bigIncrements('id');3 $table->string('title');4 $table->text('body');5 $table->sluggable();6 $table->timestamps();7}); 1namespace App; 2 3use Illuminate\Database\Eloquent\Model; 4use Pharaonic\Laravel\Sluggable\Sluggable; 5 6class Post extends Model 7{ 8 use Sluggable; 9 10 protected $fillable = ['title', 'body'];11 12 protected $sluggable = 'title';13} 1namespace App\Http\Controllers; 2 3use App\Post; 4 5class PostController extends Controller 6{ 7 public function show(string $slug) 8 { 9 return view('posts.show', [10 'post' => Post::findBySlugOrFail($slug),11 ]);12 }13}1Route::get('/posts/{slug}', 'PostController@show')->name('posts.show');2. Products With SEO and Category Slugs
One product, three independent slugs, one of them from a relation.
1class Product extends Model 2{ 3 use Sluggable; 4 5 protected $fillable = ['name', 'seo_title', 'category_id']; 6 7 public function category() 8 { 9 return $this->belongsTo(Category::class);10 }11 12 public function sluggable(): array13 {14 return [15 'slug' => 'name',16 'seo_slug' => ['source' => 'seo_title', 'on_update' => true],17 'category_slug' => fn ($model) => $model->category?->name ?? '',18 ];19 }20}1$product = Product::create([2 'name' => 'Galaxy S24 Ultra',3 'seo_title' => 'Buy Galaxy S24 Ultra Online',4 'category_id' => $phones->id,5]);6 7$product->slug; // "galaxy-s24-ultra"8$product->seo_slug; // "buy-galaxy-s24-ultra-online"9$product->category_slug; // "phones"3. Arabic Titles: Unicode or ASCII
By default the slug keeps Arabic letters:
1Post::create(['title' => 'مرحبا بالعالم'])->slug; // "مرحبا-بالعالم"For ASCII-only URLs, enable transliteration in the config:
1'ascii_only' => true,4. Stable Slugs With a Unique Key Prefix
Keep on_update disabled so shared links never break, and use slug_with_key when you want the key in the URL:
1$post = Post::create(['title' => 'Release Notes']);2 3route('posts.show', $post->slug_with_key); // "/posts/12-release-notes"1public function show(string $slugWithKey)2{3 $id = (int) strtok($slugWithKey, '-');4 5 return view('posts.show', ['post' => Post::findOrFail($id)]);6}5. Short Slugs for Share Links
Limit one slug's length without affecting the others:
1public function sluggable(): array2{3 return [4 'slug' => 'title',5 'share_slug' => ['source' => 'title', 'max_length' => 20],6 ];7}1$post = Post::create(['title' => 'A very long title about Laravel slugs']);2 3$post->slug; // "a-very-long-title-about-laravel-slugs"4$post->share_slug; // "a-very-long-title"Troubleshooting
The slug is always null
The source is empty, or it isn't a direct attribute. A string source such as 'category.name' is read as an attribute named category.name, not as a relation. Use a callable: fn ($model) => $model->category?->name ?? ''.
Also check that on_create isn't set to false in config/pharaonic/sluggable.php.
The slug didn't change after updating the title
This is the default: on_update is false so URLs stay stable. Enable it in the config or for that slug with 'on_update' => true. If you also set the slug column in the same save, your value wins.
My $sluggable property is ignored
The model (or a parent class) defines a sluggable() method. The method always takes precedence and the two are never merged. Add the slug entry to the method instead.
UNIQUE constraint failed when saving
- The model uses
SoftDeletesand a trashed row still holds the slug. Enableinclude_trashed. uniqueis disabled for a column that has a unique index. Remove the index or enableunique.- Two requests saved the same slug at the same moment. The index did its job: catch the exception and retry the save.
Column not found: seo_slug
Each key returned by sluggable() is a column that must exist. Add it with $table->sluggable('seo_slug').
Mass assignment ignores my slug
Slug columns are added to $fillable only when the model already defines $fillable. With $guarded, make sure the slug column isn't guarded.
Slugs contain non-Latin characters
That's the default Unicode behavior. Set ascii_only to true to transliterate them to ASCII.
Callable source runs extra queries
Callables that read relations load them lazily. When creating many models, load the relation first or pass the value through an attribute.
Contributors
Amazing people who made this package possible
This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Sluggable!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.