Skip to content

[13.x] Introduces #[Sluggable] attribute#59173

Open
nunomaduro wants to merge 36 commits into13.xfrom
feat/sluggable
Open

[13.x] Introduces #[Sluggable] attribute#59173
nunomaduro wants to merge 36 commits into13.xfrom
feat/sluggable

Conversation

@nunomaduro
Copy link
Member

@nunomaduro nunomaduro commented Mar 12, 2026

This PR adds first-party support for automatic slug generation on Eloquent models via a simple #[Sluggable] attribute.

Getting Started

To make an existing model sluggable, run the make:sluggable Artisan command:

php artisan make:sluggable Post

This command adds the #[Sluggable] attribute to your model and generates a migration for the slug column. It introspects the model's table to guess the source column (name, title, headline, or subject):

+#[Sluggable(from: 'title')]
class Post extends Model
{
    //
}

That's it. After running php artisan migrate, when a Post is created, a slug will be automatically generated and stored in the slug column:

$post = Post::create(['title' => 'Hello World']);
$post->slug; // "hello-world"

The command also accepts --from and --to options:

php artisan make:sluggable Post --from=headline --to=url_slug

Configuration

Every aspect of slug generation can be customized directly on the attribute.

Important: The generated migration creates a simple, unique string column. You may need to tweak it depending on your slug configuration — for example, removing the unique() constraint if unique: false, or when using scope. The same goes when adding the #[Sluggable] attribute to a model with existing data — you may need to backfill slugs before applying a unique constraint.

from

By default, the slug is generated from the name column. You may customize this using the from parameter:

#[Sluggable(from: 'title')]

Slugs may also be generated from multiple columns by passing an array:

#[Sluggable(from: ['first_name', 'last_name'])]
class Author extends Model
{
    //
}

$author = Author::create(['first_name' => 'John', 'last_name' => 'Doe']);
$author->slug; // "john-doe"
to

By default, the slug is stored in the slug column. You may customize this using the to parameter:

#[Sluggable(from: 'title', to: 'url_slug')]
scope

When slugs should be unique within a scope (e.g., per team or per locale), you may provide one or more scope columns:

#[Sluggable(scope: 'team_id')]

Multiple scope columns are also supported:

#[Sluggable(scope: ['team_id', 'locale'])]
onUpdating, separator, unique, maxLength, etc.

The attribute accepts several other options to fine-tune slug generation behavior. By default, slugs are generated on creation but not on update, use - as a separator, enforce uniqueness with up to 100 attempts, and have no maximum length:

#[Sluggable(onUpdating: false, separator: '-', unique: true, maxAttempts: 100, maxLength: 60)]

When onUpdating is enabled, the slug is regenerated whenever the source column changes. When a slug value is manually provided, it is always preserved — both on creation and on update.

errorKey

When slug generation fails, a CouldNotGenerateSlugException is thrown. In HTTP contexts, this exception automatically renders as a 422 validation error response — just like a failed validation rule. The error is attached to the first source column by default:

Screenshot 2026-03-18 at 15 25 32 Screenshot 2026-03-18 at 15 25 01

This works auto-magically in blade, livewire, inertia, etc

{
    "errors": {
        "name": ["The name cannot be converted into a valid slug."]
    }
}

You may customize the error key using the errorKey parameter:

#[Sluggable(errorKey: 'input_name')]

The error messages may be customized by publishing and modifying the validation.slug_required and validation.slug_unique translation keys in your application's language files. Both keys receive
:attribute and slug replacements:

// lang/en/validation.php
'slug_required' => 'The :attribute cannot be converted into a valid :slug.',
'slug_unique' => 'Too many :slug entries exist for the given :attribute. Please try a different value.',

Since it extends ValidationException, the exception is not reported to the application log — consistent with how Laravel handles ModelNotFoundException and other eloquent exceptions.

Slug Generation

The slug generation pipeline transliterates Unicode characters (including CJK scripts) into their Latin equivalents, preserves dots (useful for domain-like values such as laravel.com), and converts special characters into clean separators.

Here are a few examples of what the pipeline handles:

  • Basic text: Hello World → hello-world
  • Accented characters: Café Résumé → cafe-resume
  • German: Straße → strasse
  • Chinese: 如何安装 Laravel → ru-he-an-zhuang-laravel
  • Japanese: こんにちは → konnichiha
  • Korean: 안녕하세요 → annyeonghaseyo
  • Russian: Привет Мир → privet-mir
  • Dot preservation: laravel.com → laravel.com
  • Subdomains: sub.domain.example.com → sub.domain.example.com
  • File extensions: document.final.pdf → document.final.pdf
  • Unicode with dots: über.straße → uber.strasse
  • Special characters as separators: Example/Path → example-path
  • Typography: Hello — World → hello-world
  • Emojis: 🎉 Hello 🌟 World 🚀 → hello-world
  • Mixed real-world input: [2024] Annual Report (Final) → 2024-annual-report-final

@nunomaduro nunomaduro changed the title [13.x] Introduces #[Sluggable] trait [13.x] Introduces #[Sluggable] attribute Mar 13, 2026
@peaklabs-dev
Copy link

peaklabs-dev commented Mar 15, 2026

Great work as always.

I have a few suggestions for the attribute:

  • It would be very useful if we could pass in an enum (Laravel calls ::cases() internally) or an array of reserved slugs (via an option reservedSlugs) which are treated as used even if they are not used yet.
  • I personally think returning a clean validation error or even a configurable validation error, if the slug is already used instead of trying to auto-increment it, is cleaner in some cases, so an option for that would be great. This could potentially be done via maxAttempts=0 but I am not sure if I like that API naming.

And I think a sluggable validation rule would be highly beneficial, especially for Inertia projects where it often makes sense to generate the slug client-side and validate it using Zod or Valibot (if the user chooses to edit the slug manually) and then once the user saves the change, validate again server-side most importantly for uniqueness according to the scopes set with the sluggable rule.

@shaedrich
Copy link
Contributor

shaedrich commented Mar 16, 2026

To make an existing model sluggable, run the make:sluggable Artisan command:

php artisan make:sluggable Post

What about new models? Would this be a two-step process then? Wouldn't it make sense to add a flag to php artisan make:model Post --sluggable?

Honestly, I don't find any of the option names very intuitive:

old new comment
from source Consistent with target
column target Column of what? From? To? Scope?
scope unique_by Not confused with Eloquent scopes, consistent with job uniqueness

Unicode with dots: über.straße → uber.strasse

Shouldn't this be ueber.strasse?

#[Sluggable(
    onCreating: true,
    onUpdating: false,
    separator: '-',
    language: 'en',
    unique: true,
    maxAttempts: 100,
    maxLength: 60,       // default: null (no limit)
)]

When it's a multi-language model, it would be nice if one could pass the column name instead of a static locale

public function up(): void
{
Schema::table('{{table}}', function (Blueprint $table) {
$table->string('slug')->unique()->after('id');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When another value is given via column, this should be reflected here as well, shouldn't it?

Suggested change
$table->string('slug')->unique()->after('id');
$table->string('{{column}}')->unique()->after('id');

*
* @var string
*/
protected $signature = 'make:sluggable {model : The model to make sluggable}';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would mean, we need this as an option here:

Suggested change
protected $signature = 'make:sluggable {model : The model to make sluggable}';
protected $signature = 'make:sluggable {model : The model to make sluggable} {--column : Column to store the slug in}';

If one wants to skip guessing the source column, we might want to offer a way to overwrite this:

Suggested change
protected $signature = 'make:sluggable {model : The model to make sluggable}';
protected $signature = 'make:sluggable {model : The model to make sluggable} {--from : Column on whose value the slug will be based on}';

Comment on lines +32 to +35
/**
* Execute the console command.
*/
public function handle(): int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can narrow this further down:

Suggested change
/**
* Execute the console command.
*/
public function handle(): int
/**
* Execute the console command.
*
* @return self::SUCCESS|self::FAILURE|self::INVALID
*/
public function handle(): int

Comment on lines +111 to +142
/**
* Create the migration file for the slug column.
*/
protected function createMigration(string $table): int
{
if ($this->hasSlugColumn($table)) {
$this->components->warn("Table [{$table}] already has a slug column. Migration not created.");

return 0;
}

if ($this->migrationExists($table)) {
$this->components->error('Migration already exists.');

return 1;
}

$path = $this->laravel['migration.creator']->create(
'add_slug_to_'.$table.'_table',
$this->laravel->databasePath('/migrations')
);

$stub = str_replace(
'{{table}}', $table, $this->files->get(__DIR__.'/stubs/add_slug_column.stub')
);

$this->files->put($path, $stub);

$this->components->warn('Migration created. Please review it before running — you may need to adjust it based on your existing data or slug configuration.');

return 0;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use the constants here:

Suggested change
/**
* Create the migration file for the slug column.
*/
protected function createMigration(string $table): int
{
if ($this->hasSlugColumn($table)) {
$this->components->warn("Table [{$table}] already has a slug column. Migration not created.");
return 0;
}
if ($this->migrationExists($table)) {
$this->components->error('Migration already exists.');
return 1;
}
$path = $this->laravel['migration.creator']->create(
'add_slug_to_'.$table.'_table',
$this->laravel->databasePath('/migrations')
);
$stub = str_replace(
'{{table}}', $table, $this->files->get(__DIR__.'/stubs/add_slug_column.stub')
);
$this->files->put($path, $stub);
$this->components->warn('Migration created. Please review it before running — you may need to adjust it based on your existing data or slug configuration.');
return 0;
}
/**
* Create the migration file for the slug column.
*/
protected function createMigration(string $table): int
{
if ($this->hasSlugColumn($table)) {
$this->components->warn("Table [{$table}] already has a slug column. Migration not created.");
return self::SUCCESS;
}
if ($this->migrationExists($table)) {
$this->components->error('Migration already exists.');
return self::FAILURE;
}
$path = $this->laravel['migration.creator']->create(
'add_slug_to_'.$table.'_table',
$this->laravel->databasePath('/migrations')
);
$stub = str_replace(
'{{table}}', $table, $this->files->get(__DIR__.'/stubs/add_slug_column.stub')
);
$this->files->put($path, $stub);
$this->components->warn('Migration created. Please review it before running — you may need to adjust it based on your existing data or slug configuration.');
return self::SUCCESS;
}

Comment on lines +15 to +26
*/
public function __construct(
public array|string $from = 'name',
public string $column = 'slug',
public string $separator = '-',
public string $language = 'en',
public array|string $scope = [],
public bool $onCreating = true,
public bool $onUpdating = false,
public bool $unique = true,
public int $maxAttempts = 100,
public ?int $maxLength = null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could narrow this further down:

Suggested change
*/
public function __construct(
public array|string $from = 'name',
public string $column = 'slug',
public string $separator = '-',
public string $language = 'en',
public array|string $scope = [],
public bool $onCreating = true,
public bool $onUpdating = false,
public bool $unique = true,
public int $maxAttempts = 100,
public ?int $maxLength = null,
* @param non-negative-int $maxAttempts
* @param non-negative-int $maxLength
*/
public function __construct(
public array|string $from = 'name',
public string $column = 'slug',
public string $separator = '-',
public string $language = 'en',
public array|string $scope = [],
public bool $onCreating = true,
public bool $onUpdating = false,
public bool $unique = true,
public int $maxAttempts = 100,
public ?int $maxLength = null,

public function up(): void
{
Schema::table('{{table}}', function (Blueprint $table) {
$table->string('slug')->unique()->after('id');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the columns uniqueness isn't "globally" unique for the whole table but within a scope, does the unique() even work or does this have to be a check statement then or validation in the model? Also, when unique: false, then we don't need a unique() here at all

Comment on lines +396 to +411
'ampersand' => ['Tom & Jerry', 'tom-jerry'],
'plus sign' => ['C++ Programming', 'c-programming'],
'parentheses' => ['Hello (World)', 'hello-world'],
'brackets' => ['Hello [World]', 'hello-world'],
'curly braces' => ['Hello {World}', 'hello-world'],
'exclamation' => ['Hello World!', 'hello-world'],
'question mark' => ['What Is This?', 'what-is-this'],
'comma' => ['Hello, World', 'hello-world'],
'semicolon' => ['Example;Path', 'example-path'],
'colon' => ['Example:Path', 'example-path'],
'quotes single' => ["It's Here", 'it-s-here'],
'quotes double' => ['"Hello World"', 'hello-world'],
'at sign' => ['user@host', 'user-host'],
'hash' => ['Hello#World', 'hello-world'],
'dollar' => ['100$ Deal', '100-deal'],
'percent' => ['100% Done', '100-done'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could replace some of them:

  • & → and
  • % → percent

Comment on lines +479 to +484
'emoji suffix' => ['Example 😊', 'example'],
'emoji prefix' => ['🚀 Example', 'example'],
'emoji between words' => ['Hello 🌍 World', 'hello-world'],
'emoji inline' => ['Test🔥Case', 'testcase'],
'emoji only prefix' => ['💡Test', 'test'],
'multiple emojis' => ['🎉 Hello 🌟 World 🚀', 'hello-world'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add an option to use the emoji name:

$value = preg_replace_callback(
    '(?:[\x{1F600}-\x{1F64F}]|[\x{1F300}-\x{1F5FF}]|[\x{1F680}-\x{1F6FF}]|[\x{1F900}-\x{1F9FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]|[\x{1F1E0}-\x{1F1FF}]|[\x{FE00}-\x{FE0F}]|[\x{200D}])+'/gu,
    fn ($emoji) => IntlChar::charName($emoji),
    $value,
);

Comment on lines +505 to +527
'euro sign' => ['Price €100', 'price-eur100'],
'pound sign' => ['Weight £50', 'weight-ps50'],
'degree sign' => ['Temp °C', 'temp-degc'],
'section sign' => ['Section §1', 'section-ss1'],
'copyright' => ['©2024 Company', 'c-2024-company'],
'registered' => ['®Brand', 'r-brand'],

// dashes and typography
'em dash with spaces' => ['Hello — World', 'hello-world'],
'en dash' => ['Hello – World', 'hello-world'],
'em dash' => ['Hello — World', 'hello-world'],
'double hyphen' => ['hello--world', 'hello-world'],
'leading double hyphen' => ['--hello--world--', 'hello-world'],
'test with triple hyphens' => ['Test---Case', 'test-case'],
'test with triple underscores' => ['Test___Case', 'test-case'],
'test hyphen spaced' => ['Test - Case', 'test-case'],
'test underscore spaced' => ['Test _ Case', 'test-case'],

// complex real-world
'filename with version and copy' => ['file_name-v2.0 (copy)', 'file-name-v2.0-copy'],
'bracketed year report' => ['[2024] Annual Report (Final)', '2024-annual-report-final'],
'qa with symbols' => ['Q&A: Common Questions!', 'q-a-common-questions'],
'price with slash' => ['Price: $49.99/month', 'price-49.99-month'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are and £ transliterated but not $?

return 'name';
}

return collect(['name', 'title', 'headline', 'subject'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be configurable?

@nunomaduro nunomaduro marked this pull request as ready for review March 17, 2026 17:03
@browner12
Copy link
Contributor

IMO this does not feel like the responsibility of the framework. This seems like a very narrow scoped problem that should be solved at the application level.

The other part I'm struggling with is all of this seems very easy to do in application code. Is there something difficult that this is making a lot easier that I'm not seeing?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants