Laravel Packagev9.0.0MIT License

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.

Quick Tip

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, 8.1 or 8.2
  • Laravel 9.33 or newer within 9.x
  • pharaonic/php-slugify 8.0.4+ on PHP 8.0, 8.1.2+ on PHP 8.1 or 8.2.1+ on PHP 8.2 (installed automatically)

Composer Installation

Terminal
composer require pharaonic/laravel-sluggable

Publish Configuration

Publishing the config is optional. Without it, the package uses its defaults (separator -, unique slugs, generated on create only).

Terminal
php artisan vendor:publish --tag=sluggable-config

This creates config/pharaonic/sluggable.php.

Publish Tags

--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:

database/migrations/2020_01_01_000000_create_posts_table.php
1Schema::create('posts', function (Blueprint $table) {
2 $table->id();
3 $table->string('title');
4 $table->sluggable(); // "slug": string, nullable, unique
5 $table->timestamps();
6});
Installation Complete

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.

config/pharaonic/sluggable.php
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

KeyDefaultDescription
separator-Joins the words of the slug and the unique suffix.
uniquetrueAppend -2, -3, ... when the slug is already taken in its column.
on_createtrueGenerate slugs when a model is created (only for empty slug columns).
on_updatefalseRegenerate a slug when its source changes on update.
include_trashedfalseAlso check soft-deleted rows when looking for a unique slug.
max_length255Maximum slug length in characters, suffix included. null means no limit.
ascii_onlyfalseTransliterate slugs to ASCII (Crème brûlée → creme-brulee).
ascii_langAPP_LOCALE or enLanguage passed to PHP Slugify for language-specific rules.

Resolution Order

Each option is resolved per slug:

per-slug option → config/pharaonic/sluggable.php → package default

The $sluggable property has no per-slug options, so it always uses the config.

Read on Every Save

The config is read each time a model is saved, not once at boot. Changing it at runtime with config(['pharaonic.sluggable.on_update' => true]) applies to the next save, which is handy in tests and seeders.

Unicode by Default

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.

app/Models/Post.php
1namespace App\Models;
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 null
2Post::findBySlugOrFail('hello-world'); // Post or ModelNotFoundException
3Post::whereSlug('hello-world')->first();

Route model binding can resolve the model by slug by naming the column in the route:

routes/web.php
1Route::get('/posts/{post:slug}', function (App\Models\Post $post) {
2 return $post;
3});

To always bind by slug, return the column from getRouteKeyName() on the model instead:

app/Models/Post.php
1public function getRouteKeyName()
2{
3 return 'slug';
4}

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"
Direct Attributes Only

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.

app/Models/Product.php
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:

database/migrations/2020_01_01_000000_create_products_table.php
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

Precedence

When both $sluggable and sluggable() are defined, the sluggable() method always takes precedence. The two configurations are never merged.

1protected $sluggable = 'title'; // ignored
2 
3public function sluggable(): array
4{
5 return ['seo_slug' => 'seo_title']; // only seo_slug is generated
6}

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.

app/Models/Post.php
1public function sluggable(): array
2{
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

SourceRead 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.

Relations Are Not Loaded For You

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.

app/Models/Post.php
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

OptionTypeDescription
sourcestring or callableAttribute name, or a callable receiving the model and returning a string. Required.
separatorstringJoins the words and the unique suffix.
uniqueboolAppend a suffix when the slug is taken in this column.
on_updateboolRegenerate this slug when its source changes.
include_trashedboolCheck soft-deleted rows for this slug's uniqueness.
max_lengthint or nullMaximum 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-N variants. The next number is the highest existing suffix plus one, so a gap such as hello-7 continues with hello-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-world doesn't block hello.
  • % and _ in a slug are escaped in the LIKE match.

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],
Unique Index and Trashed Rows

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-2 for hello), the current slug is kept.
  • If you change the slug column yourself in the same save, your value wins.
Broken Links

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.

database/migrations/2020_01_01_000000_create_posts_table.php
1Schema::create('posts', function (Blueprint $table) {
2 $table->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

database/migrations/2020_01_02_000000_add_slug_to_posts_table.php
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.

resources/views/posts/index.blade.php
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')
Escaping

@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

MemberDescription
protected $sluggable = 'title';Generate slug from one direct attribute, using the config.
public function sluggable(): arrayReturn column => source or column => [options] for any number of slugs. Takes precedence over $sluggable.

Methods

MethodDescriptionReturns
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

OptionDefaultDescription
source—Attribute name or callable. Per-slug only.
separator-Word and suffix separator.
uniquetrueAppend -2, -3, ... on collisions.
on_createtrueGenerate on create (config only).
on_updatefalseRegenerate when the source changes.
include_trashedfalseInclude soft-deleted rows in the uniqueness check.
max_length255Maximum length, suffix included. null for no limit.
ascii_onlyfalseTransliterate to ASCII (config only).
ascii_langAPP_LOCALE / enLanguage hint for PHP Slugify (config only).

Schema Macro

MacroCreates
$table->sluggable(string $column = 'slug')string($column)->nullable()->unique()

Blade Directive

DirectiveOutput
@slug($value, $separator = '-', $asciiOnly = false, $language = 'en')The slugified value

Publish Tags

TagPublishes
sluggable-config, laravel-sluggable, pharaonic, pharaonic-configconfig/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.

database/migrations/2020_01_01_000000_create_posts_table.php
1Schema::create('posts', function (Blueprint $table) {
2 $table->id();
3 $table->string('title');
4 $table->text('body');
5 $table->sluggable();
6 $table->timestamps();
7});
app/Models/Post.php
1namespace App\Models;
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}
app/Http/Controllers/PostController.php
1namespace App\Http\Controllers;
2 
3use App\Models\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}
routes/web.php
1use App\Http\Controllers\PostController;
2 
3Route::get('/posts/{slug}', [PostController::class, 'show'])->name('posts.show');

2. Products With SEO and Category Slugs

One product, three independent slugs, one of them from a relation.

app/Models/Product.php
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(): array
13 {
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:

config/pharaonic/sluggable.php
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"
app/Http/Controllers/PostController.php
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(): array
2{
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 SoftDeletes and a trashed row still holds the slug. Enable include_trashed.
  • unique is disabled for a column that has a unique index. Remove the index or enable unique.
  • 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!

Moamen Eltouny

@MoamenEltouny

115 contributions

Elsayed Elbeshry

@Elsayed93

1 contribution

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.