Conversation
#[Sluggable] trait#[Sluggable] attribute
src/Illuminate/Database/Console/Sluggable/stubs/add_slug_column.stub
Outdated
Show resolved
Hide resolved
|
Great work as always. I have a few suggestions for the attribute:
And I think a |
What about new models? Would this be a two-step process then? Wouldn't it make sense to add a flag to Honestly, I don't find any of the option names very intuitive:
Shouldn't this be
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'); |
There was a problem hiding this comment.
When another value is given via column, this should be reflected here as well, shouldn't it?
| $table->string('slug')->unique()->after('id'); | |
| $table->string('{{column}}')->unique()->after('id'); |
| * | ||
| * @var string | ||
| */ | ||
| protected $signature = 'make:sluggable {model : The model to make sluggable}'; |
There was a problem hiding this comment.
This would mean, we need this as an option here:
| 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:
| 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}'; |
| /** | ||
| * Execute the console command. | ||
| */ | ||
| public function handle(): int |
There was a problem hiding this comment.
You can narrow this further down:
| /** | |
| * Execute the console command. | |
| */ | |
| public function handle(): int | |
| /** | |
| * Execute the console command. | |
| * | |
| * @return self::SUCCESS|self::FAILURE|self::INVALID | |
| */ | |
| public function handle(): int |
| /** | ||
| * 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; | ||
| } |
There was a problem hiding this comment.
You could use the constants here:
| /** | |
| * 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; | |
| } |
| */ | ||
| 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, |
There was a problem hiding this comment.
You could narrow this further down:
| */ | |
| 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'); |
There was a problem hiding this comment.
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
| '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'], |
There was a problem hiding this comment.
We could replace some of them:
- & → and
- % → percent
| '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'], |
There was a problem hiding this comment.
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,
);| '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'], |
There was a problem hiding this comment.
Why are € and £ transliterated but not $?
| return 'name'; | ||
| } | ||
|
|
||
| return collect(['name', 'title', 'headline', 'subject']) |
There was a problem hiding this comment.
Should this be configurable?
c004f1d to
dd4df97
Compare
|
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? |
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:sluggableArtisan command: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 theslugcolumn:The command also accepts
--fromand--tooptions: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 ifunique: false, or when usingscope. 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.fromBy default, the slug is generated from the
namecolumn. You may customize this using the from parameter:#[Sluggable(from: 'title')]Slugs may also be generated from multiple columns by passing an array:
toBy default, the slug is stored in the
slugcolumn. You may customize this using thetoparameter:scopeWhen 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:
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:When
onUpdatingis 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.errorKeyWhen slug generation fails, a
CouldNotGenerateSlugExceptionis thrown. In HTTP contexts, this exception automatically renders as a422validation error response — just like a failed validation rule. The error is attached to the first source column by default: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
errorKeyparameter:#[Sluggable(errorKey: 'input_name')]The error messages may be customized by publishing and modifying the
validation.slug_requiredandvalidation.slug_uniquetranslation keys in your application's language files. Both keys receive:attributeandslugreplacements:Since it extends
ValidationException, the exception is not reported to the application log — consistent with how Laravel handlesModelNotFoundExceptionand 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: