├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── tests.yml ├── .semver ├── .gitignore ├── phpstan.dist.neon ├── tests ├── Models │ ├── PostWithEagerRelation.php │ ├── PostWithMultipleSlugsAndCustomSlugKey.php │ ├── PostWithMultipleSlugsAndHelperTrait.php │ ├── PostWithSoftDeleting.php │ ├── PostShortConfig.php │ ├── PostWithCustomEngine2.php │ ├── PostWithMultipleSources.php │ ├── PostWithMaxLength.php │ ├── Author.php │ ├── PostWithNoSource.php │ ├── PostWithCustomSource.php │ ├── PostWithFirstUniqueSuffix.php │ ├── PostShortConfigWithScopeHelpers.php │ ├── PostWithForeignRuleset.php │ ├── PostWithOnUpdate.php │ ├── PostWithIncludeTrashed.php │ ├── PostWithEmptySeparator.php │ ├── PostWithCustomMethodArrayCall.php │ ├── PostWithIdSource.php │ ├── PostWithReservedSlug.php │ ├── PostWithCustomSeparator.php │ ├── PostWithMaxLengthSplitWords.php │ ├── PostWithCustomEngine.php │ ├── PostWithMultipleSlugs.php │ ├── PostWithCustomMethod.php │ ├── PostWithForeignRuleset2.php │ ├── PostWithSoftDeletingIncludeTrashed.php │ ├── PostWithCustomEngineOptions.php │ ├── PostWithCustomCallableMethod.php │ ├── PostWithCustomSuffix.php │ ├── PostWithRelation.php │ ├── PostWithIdSourceOnSaved.php │ ├── PostWithUniqueSlugConstraints.php │ ├── PostNotSluggable.php │ └── Post.php ├── Classes │ └── SluggableCustomMethod.php ├── Listeners │ ├── AbortSlugging.php │ └── DoNotAbortSlugging.php ├── TestServiceProvider.php ├── database │ └── migrations │ │ ├── 2015_08_17_185144_authors.php │ │ └── 2013_11_04_163552_posts.php ├── RelationTests.php ├── TestCase.php ├── StaticTests.php ├── SoftDeleteTests.php ├── EventTests.php ├── UniqueTests.php ├── OnUpdateTests.php ├── ScopeHelperTests.php └── BaseTests.php ├── phpunit.xml ├── TODO.md ├── LICENSE.md ├── src ├── ServiceProvider.php ├── SluggableScopeHelpers.php ├── SluggableObserver.php ├── Sluggable.php └── Services │ └── SlugService.php ├── SCOPE-HELPERS.md ├── ROUTE-MODEL-BINDING.md ├── composer.json ├── .php-cs-fixer.php ├── CONTRIBUTING.md ├── UPGRADING.md ├── resources └── config │ └── sluggable.php ├── CHANGELOG.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: cviebrock 2 | -------------------------------------------------------------------------------- /.semver: -------------------------------------------------------------------------------- 1 | --- 2 | :major: 12 3 | :minor: 0 4 | :patch: 0 5 | :special: '' 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.php-cs-fixer.cache 2 | /.phpunit.cache 3 | /.idea/ 4 | /build/ 5 | /vendor/ 6 | composer.lock 7 | -------------------------------------------------------------------------------- /phpstan.dist.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | level: 4 6 | paths: 7 | - resources 8 | - src 9 | - tests 10 | editorUrl: '%%relFile%%:%%line%%' 11 | editorUrlTitle: '%%relFile%%:%%line%%' 12 | -------------------------------------------------------------------------------- /tests/Models/PostWithEagerRelation.php: -------------------------------------------------------------------------------- 1 | '|[^A-Za-z0-9/]+|']); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Models/PostWithMultipleSources.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => ['title', 'subtitle'], 18 | ], 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Models/PostWithMaxLength.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => 'title', 18 | 'maxLength' => 10, 19 | ], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Models/Author.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => null, 20 | ], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomSource.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'subtitle', 20 | ], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Models/PostWithFirstUniqueSuffix.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => 'title', 18 | 'firstUniqueSuffix' => '42', 19 | ], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Models/PostShortConfigWithScopeHelpers.php: -------------------------------------------------------------------------------- 1 | activateRuleSet('esperanto'); 20 | 21 | return $engine; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Models/PostWithOnUpdate.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'onUpdate' => true, 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models/PostWithIncludeTrashed.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => 'title', 18 | 'includeTrashed' => true, 19 | ], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/Models/PostWithEmptySeparator.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'separator' => '', 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomMethodArrayCall.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'source' => 'title', 17 | 'method' => [SluggableCustomMethod::class, 'slug'], 18 | ], 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Models/PostWithIdSource.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => ['title', 'id'], 20 | 'onUpdate' => true, 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models/PostWithReservedSlug.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'reserved' => ['add', 'add-1'], 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomSeparator.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'separator' => '.', 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Models/PostWithMaxLengthSplitWords.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => 'title', 18 | 'maxLength' => 10, 19 | 'maxLengthKeepWords' => false, 20 | ], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomEngine.php: -------------------------------------------------------------------------------- 1 | addRule('e', 'a'); 17 | $engine->addRule('i', 'a'); 18 | $engine->addRule('o', 'a'); 19 | $engine->addRule('u', 'a'); 20 | 21 | return $engine; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Models/PostWithMultipleSlugs.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'source' => 'title', 18 | ], 19 | 'dummy' => [ 20 | 'source' => 'subtitle', 21 | 'separator' => '.', 22 | ], 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/TestServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom( 25 | __DIR__ . '/database/migrations' 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomMethod.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'method' => function ($string, $separator) { 21 | return strrev(Str::slug($string, $separator)); 22 | }, 23 | ], 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Models/PostWithForeignRuleset2.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'slugEngineOptions' => [ 21 | 'ruleset' => 'finnish', 22 | ], 23 | ], 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Models/PostWithSoftDeletingIncludeTrashed.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'source' => 'title', 22 | 'includeTrashed' => true, 23 | ], 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomEngineOptions.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'slugEngineOptions' => [ 21 | 'lowercase' => false, 22 | ], 23 | ], 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/database/migrations/2015_08_17_185144_authors.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->string('name'); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::drop('authors'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please be sure you include all the relevant information in your issue so we can help you: 2 | 3 | - [ ] steps taken to reproduce your issue 4 | - [ ] your configuration file(s), where relevant 5 | - [ ] a copy of your Models that use or extend this package (just the relevant parts!) 6 | - [ ] any other code we might need to help 7 | 8 | Please use a descriptive title for your issue. "Why doesn't this work?" doesn't provide 9 | other users much information if they are scanning the list of issues. 10 | 11 | Also, *please* use [fenced code blocks](https://help.github.com/articles/creating-and-highlighting-code-blocks/) 12 | when pasting more than one line of code. It makes it so much more readable for everyone! 13 | 14 | **Thank you!** 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thank you for helping to make this package better! 2 | 3 | Please make sure you've read [CONTRIBUTING.md](https://github.com/cviebrock/eloquent-sluggable/blob/master/CONTRIBUTING.md) 4 | before submitting your pull request, and that you have: 5 | 6 | - [ ] provided a rationale for your change (I try not to add features that are going to have a limited user-base) 7 | - [ ] checked your coding style with `composer run style:check` 8 | - [ ] analyzed your code with `composer run analyze` 9 | - [ ] added tests 10 | - [ ] confirm all old and new tests pass with `composer run tests` 11 | - [ ] documented any change in behaviour (e.g. updated the `README.md`, etc.) 12 | - [ ] only submitted one pull request per feature 13 | 14 | **Thank you!** 15 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomCallableMethod.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'source' => 'title', 20 | 'method' => [static::class, 'makeSlug'], 21 | ], 22 | ]; 23 | } 24 | 25 | public static function makeSlug($string, $separator) 26 | { 27 | return strrev(Str::slug($string, $separator)); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Models/PostWithCustomSuffix.php: -------------------------------------------------------------------------------- 1 | [ 21 | 'source' => 'title', 22 | 'uniqueSuffix' => function ($slug, $separator, Collection $list) { 23 | $size = count($list); 24 | 25 | return chr($size + 96); 26 | }, 27 | ], 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Models/PostWithRelation.php: -------------------------------------------------------------------------------- 1 | [ 23 | 'source' => ['author.name', 'title'], 24 | ], 25 | ]; 26 | } 27 | 28 | /** 29 | * Relation to Author model. 30 | */ 31 | public function author(): BelongsTo 32 | { 33 | return $this->belongsTo(Author::class); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Models/PostWithIdSourceOnSaved.php: -------------------------------------------------------------------------------- 1 | [ 30 | 'source' => ['title', 'id'], 31 | 'onUpdate' => true, 32 | ], 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Models/PostWithUniqueSlugConstraints.php: -------------------------------------------------------------------------------- 1 | belongsTo(Author::class); 22 | } 23 | 24 | /** 25 | * {@inheritDoc} 26 | */ 27 | public function scopeWithUniqueSlugConstraints(Builder $query, Model $model, $attribute, $config, $slug): Builder 28 | { 29 | /** @var self $model */ 30 | $author = $model->author; 31 | 32 | return $query->where('author_id', $author->getKey()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests/ 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | src/ 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/database/migrations/2013_11_04_163552_posts.php: -------------------------------------------------------------------------------- 1 | increments('id'); 19 | $table->string('title'); 20 | $table->string('subtitle')->nullable(); 21 | $table->string('slug')->nullable(); 22 | $table->string('dummy')->nullable(); 23 | $table->integer('author_id')->nullable(); 24 | $table->softDeletes(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::drop('posts'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Models/PostNotSluggable.php: -------------------------------------------------------------------------------- 1 | title; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Todos 2 | 3 | - [x] Write tests 4 | - [ ] Test events 5 | - [x] Better docblock and inline-commenting 6 | - [x] Make code style consistent 7 | - [x] Drop `develop` branch and just have `master` and tagged releases 8 | - [x] Add check that model uses softDelete trait when using `with_trashed` (see issue #47) 9 | 10 | ## Planned changes (possibly BC-breaking) for next major version - 4.0 11 | 12 | - [x] switch default slugging method from `Str::slug` to an external package/class that can handle transliteration of other languages (e.g. https://github.com/cocur/slugify) 13 | - [x] provide interface into `cocur/slugify` to allow for custom rules, etc. 14 | - [X] convert `findBySlug` into a scope (as suggested by @unitedworks in #40) 15 | - [x] more configurable `unique` options (see issue #53) 16 | - [x] refactor, or remove, caching code (it wasn't really thought out well enough, IMO) 17 | - [x] add events, e.g. `eloquent.slug.created`, `eloquent.slug.changed`, etc. (as suggested in #96 and #101) 18 | 19 | ## Planned changes (possibly BC-breaking) for next major version - 4.3 20 | 21 | - [x] remove unused `$model` argument from `scopeFindSimilarSlugs` 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Colin Viebrock 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in all 13 | > copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | > SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/RelationTests.php: -------------------------------------------------------------------------------- 1 | 'Arthur Conan Doyle', 25 | ]); 26 | $post = new PostWithEagerRelation([ 27 | 'title' => 'My First Post', 28 | ]); 29 | $post->author()->associate($author); 30 | $post->save(); 31 | 32 | self::assertEquals('arthur-conan-doyle-my-first-post', $post->slug); 33 | 34 | $post2 = new PostWithEagerRelation([ 35 | 'title' => 'My second post', 36 | ]); 37 | $post2->author()->associate($author); 38 | $post2->save(); 39 | self::assertEquals('arthur-conan-doyle-my-second-post', $post2->slug); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | title; 45 | } 46 | 47 | /** 48 | * Return the sluggable configuration array for this model. 49 | */ 50 | public function sluggable(): array 51 | { 52 | return [ 53 | 'slug' => [ 54 | 'source' => 'title', 55 | ], 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | setUpConfig(); 21 | } 22 | 23 | /** 24 | * Register the application services. 25 | */ 26 | public function register() 27 | { 28 | $this->app->singleton(SluggableObserver::class, function ($app) { 29 | return new SluggableObserver(new SlugService(), $app['events']); 30 | }); 31 | } 32 | 33 | protected function setUpConfig(): void 34 | { 35 | $source = dirname(__DIR__) . '/resources/config/sluggable.php'; 36 | 37 | if ($this->app instanceof LaravelApplication) { 38 | $this->publishes([$source => config_path('sluggable.php')], 'config'); 39 | // @phpstan-ignore-next-line 40 | } elseif ($this->app instanceof LumenApplication) { 41 | $this->app->configure('sluggable'); // @phpstan-ignore class.notFound 42 | } 43 | 44 | $this->mergeConfigFrom($source, 'sluggable'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | artisan('migrate', ['--database' => 'testbench']); 22 | 23 | $this->beforeApplicationDestroyed(function () { 24 | $this->artisan('migrate:rollback'); 25 | }); 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | protected function getEnvironmentSetUp($app) 32 | { 33 | // set up database configuration 34 | $app['config']->set('database.default', 'testbench'); 35 | $app['config']->set('database.connections.testbench', [ 36 | 'driver' => 'sqlite', 37 | 'database' => ':memory:', 38 | 'prefix' => '', 39 | ]); 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | */ 45 | protected function getPackageProviders($app) 46 | { 47 | return [ 48 | ServiceProvider::class, 49 | TestServiceProvider::class, 50 | ]; 51 | } 52 | 53 | /** 54 | * Mock the event dispatcher so all events are silenced and collected. 55 | * 56 | * @return $this 57 | */ 58 | protected function withoutEvents(): self 59 | { 60 | $mock = \Mockery::mock(Dispatcher::class); 61 | 62 | $mock->shouldReceive('fire', 'until'); 63 | 64 | $this->app->instance('events', $mock); 65 | 66 | return $this; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/StaticTests.php: -------------------------------------------------------------------------------- 1 | 'My Test Post']); 30 | self::assertEquals('my-test-post', $post->slug); 31 | 32 | $slug = SlugService::createSlug(Post::class, 'slug', 'My Test Post'); 33 | self::assertEquals('my-test-post-2', $slug); 34 | } 35 | 36 | /** 37 | * Test that we can generate a slug statically with different configuration. 38 | */ 39 | public function testStaticSlugGeneratorWithConfig(): void 40 | { 41 | $config = [ 42 | 'separator' => '.', 43 | ]; 44 | $slug = SlugService::createSlug(Post::class, 'slug', 'My Test Post', $config); 45 | self::assertEquals('my.test.post', $slug); 46 | } 47 | 48 | /** 49 | * Test passing an invalid attribute to static method. 50 | */ 51 | public function testStaticSlugWithInvalidAttribute(): void 52 | { 53 | $this->expectException(\InvalidArgumentException::class); 54 | SlugService::createSlug(Post::class, 'foo', 'My Test Post'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | php: 13 | - version: 8.2 14 | - version: 8.3 15 | - version: 8.4 16 | env: PHP_CS_FIXER_IGNORE_ENV=1 17 | stability: [prefer-lowest, prefer-stable] 18 | 19 | name: PHP ${{ matrix.php.version }} - ${{ matrix.stability }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | - name: Setup PHP 25 | uses: shivammathur/setup-php@v2 26 | with: 27 | php-version: ${{ matrix.php.version }} 28 | tools: composer:v2, cs2pr 29 | coverage: none 30 | - name: Setup problem matchers for PHPUnit 31 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 32 | - name: Get composer cache directory 33 | id: composer-cache 34 | run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" 35 | - name: Cache dependencies 36 | uses: actions/cache@v4 37 | with: 38 | path: ${{ steps.composer-cache.outputs.dir }} 39 | key: dependencies-${{ matrix.php.version }}-${{ matrix.stability }}-composer-${{ hashFiles('**/composer.lock') }} 40 | restore-keys: dependencies-${{ matrix.php.version }}-${{ matrix.stability }}-composer- 41 | - name: Install dependencies 42 | run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction 43 | - name: Check coding style 44 | run: ${{ matrix.php.env }} composer run style:check -- --format=checkstyle | cs2pr 45 | - name: Check static analysis 46 | run: composer run analyze -- --error-format=checkstyle | cs2pr 47 | - name: Configure matchers for PHPUnit 48 | uses: mheap/phpunit-matcher-action@v1 49 | - name: Run PHP tests 50 | run: composer run tests:ci 51 | -------------------------------------------------------------------------------- /tests/SoftDeleteTests.php: -------------------------------------------------------------------------------- 1 | 'A Post Title', 23 | ]); 24 | self::assertEquals('a-post-title', $post1->slug); 25 | 26 | $post1->delete(); 27 | 28 | $post2 = PostWithSoftDeleting::create([ 29 | 'title' => 'A Post Title', 30 | ]); 31 | self::assertEquals('a-post-title', $post2->slug); 32 | } 33 | 34 | /** 35 | * Test uniqueness with soft deletes when we include trashed models. 36 | */ 37 | public function testSoftDeletesWithTrashed(): void 38 | { 39 | $post1 = PostWithSoftDeletingIncludeTrashed::create([ 40 | 'title' => 'A Post Title', 41 | ]); 42 | self::assertEquals('a-post-title', $post1->slug); 43 | 44 | $post1->delete(); 45 | 46 | $post2 = PostWithSoftDeletingIncludeTrashed::create([ 47 | 'title' => 'A Post Title', 48 | ]); 49 | self::assertEquals('a-post-title-2', $post2->slug); 50 | } 51 | 52 | /** 53 | * Test that include_trashed is ignored if the model doesn't use the softDelete trait. 54 | */ 55 | public function testSoftDeletesWithNonSoftDeleteModel(): void 56 | { 57 | $post1 = PostWithIncludeTrashed::create([ 58 | 'title' => 'A Post Title', 59 | ]); 60 | self::assertEquals('a-post-title', $post1->slug); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /SCOPE-HELPERS.md: -------------------------------------------------------------------------------- 1 | # SluggableScopeHelpers Trait 2 | 3 | The `SluggableScopeHelpers` trait adds a query scope and other methods to make finding models with a 4 | matching slug as easy as: 5 | 6 | ```php 7 | $post = Post::findBySlug($slugString); 8 | $post = Post::findBySlugOrFail($slugString); 9 | ``` 10 | 11 | These two methods have the same signature and functionality as Eloquent's `find()` and `findOrFail()` methods 12 | except that they use the slug field instead of the primary key. 13 | 14 | The helper trait also adds a query scope to help limit searches to a particular slug: 15 | 16 | ```php 17 | $post = Post::where('author_id', '=', 3) 18 | ->whereSlug($slug) 19 | ->get(); 20 | ``` 21 | 22 | By default, the trait looks at your `sluggable()` method and uses the first slug that's defined in the configuration 23 | array for the helper scopes and methods. If your model has more than one slugged field, you will either need to 24 | put the field to be used for scopes first, or define an additional property on your model which indicates which 25 | slug is the "primary" one: 26 | 27 | ```php 28 | use Cviebrock\EloquentSluggable\Sluggable; 29 | use Cviebrock\EloquentSluggable\SluggableScopeHelpers; 30 | use Illuminate\Database\Eloquent\Model; 31 | 32 | class Post extends Model 33 | { 34 | use Sluggable; 35 | use SluggableScopeHelpers; 36 | 37 | protected $slugKeyName = 'alternate'; 38 | 39 | /** 40 | * Sluggable configuration. 41 | * 42 | * @var array 43 | */ 44 | public function sluggable(): array 45 | { 46 | return [ 47 | 'slug' => [ 48 | 'source' => 'title', 49 | ], 50 | 'alternate' => [ 51 | 'source' => 'subtitle', 52 | ] 53 | ]; 54 | } 55 | } 56 | ``` 57 | 58 | In the above case, `Post::findBySlugOrFail($slug)` is the equivalent to `Post::where('alternate,'=',$slug)->firstOrFail()`. 59 | 60 | 61 | - - - 62 | 63 | Copyright (c) 2013 Colin Viebrock 64 | -------------------------------------------------------------------------------- /ROUTE-MODEL-BINDING.md: -------------------------------------------------------------------------------- 1 | # Route Model Binding and Eloquent-Sluggable 2 | 3 | Route Model Binding is easy to implement with only minor configuration to your models. 4 | 5 | 6 | ## Implicit Binding 7 | 8 | Implicit binding requires adding a `getRouteKeyName()` method to your model that returns the name 9 | of the slug field: 10 | 11 | ```php 12 | use Cviebrock\EloquentSluggable\Sluggable; 13 | use Cviebrock\EloquentSluggable\SluggableScopeHelpers; 14 | use Illuminate\Database\Eloquent\Model; 15 | 16 | class Post extends Model 17 | { 18 | use Sluggable, SluggableScopeHelpers; 19 | 20 | public function sluggable(): array 21 | { 22 | return [ 23 | 'slug' => [ 24 | 'source' => 'title', 25 | ] 26 | ]; 27 | } 28 | 29 | /** 30 | * Get the route key for the model. 31 | * 32 | * @return string 33 | */ 34 | public function getRouteKeyName(): string 35 | { 36 | return 'slug'; 37 | } 38 | 39 | } 40 | ``` 41 | 42 | From there, you can set up your routes as described in the Eloquent documentation: 43 | 44 | ```php 45 | Route::get('api/posts/{post}', function(App\Post $post): string { 46 | return $post->title; 47 | }); 48 | ``` 49 | 50 | In this example, since the Eloquent type-hinted `$post` variable defined on the route 51 | matches the {post} segment in the route's URI, Laravel will automatically inject the 52 | model instance that has a slug matching the corresponding value from the request URI. 53 | 54 | Further, if you are using the [SluggableScopeHelpers](SCOPE-HELPERS.md) trait, you can bind 55 | the default slug to the route parameter with: 56 | 57 | ```php 58 | public function getRouteKeyName(): string 59 | { 60 | return $this->getSlugKeyName(); 61 | } 62 | ``` 63 | 64 | 65 | ## Explicit Binding 66 | 67 | You can also use the `RouteServiceProvider::boot` method as described in the 68 | [Laravel Documentation](https://laravel.com/docs/routing#route-model-binding) to 69 | handle explicit route model binding. 70 | 71 | 72 | - - - 73 | 74 | Copyright (c) 2013 Colin Viebrock 75 | -------------------------------------------------------------------------------- /tests/EventTests.php: -------------------------------------------------------------------------------- 1 | 'My Test Post', 29 | ]); 30 | 31 | Event::assertDispatched('eloquent.slugging: ' . Post::class); 32 | Event::assertDispatched('eloquent.slugged: ' . Post::class); 33 | } 34 | 35 | /** 36 | * Test that the "slugging" event can be cancelled. 37 | */ 38 | public function testDoNotCancelSluggingEventWhenItReturnsAnythingOtherThanFalse(): void 39 | { 40 | Event::fake([ 41 | 'eloquent.slugged: ' . Post::class, 42 | ]); 43 | 44 | $this->app['events']->listen('eloquent.slugging: ' . Post::class, DoNotAbortSlugging::class); 45 | 46 | $post = Post::create([ 47 | 'title' => 'My Test Post', 48 | ]); 49 | 50 | self::assertEquals('my-test-post', $post->slug); 51 | Event::assertDispatched('eloquent.slugged: ' . Post::class); 52 | } 53 | 54 | public function testCancelSluggingEvent(): void 55 | { 56 | Event::fake([ 57 | 'eloquent.slugged: ' . Post::class, 58 | ]); 59 | 60 | $this->app['events']->listen('eloquent.slugging: ' . Post::class, AbortSlugging::class); 61 | 62 | $post = Post::create([ 63 | 'title' => 'My Test Post', 64 | ]); 65 | 66 | self::assertEquals(null, $post->slug); 67 | Event::assertNotDispatched('eloquent.slugged: ' . Post::class); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SluggableScopeHelpers.php: -------------------------------------------------------------------------------- 1 | slugKeyName; 25 | } 26 | 27 | $config = $this->sluggable(); 28 | $name = reset($config); 29 | $key = key($config); 30 | 31 | // check for short configuration 32 | if ($key === 0) { 33 | return $name; 34 | } 35 | 36 | return $key; 37 | } 38 | 39 | /** 40 | * Primary slug value of this model. 41 | */ 42 | public function getSlugKey(): string 43 | { 44 | return $this->getAttribute($this->getSlugKeyName()); 45 | } 46 | 47 | /** 48 | * Query scope for finding a model by its primary slug. 49 | */ 50 | public function scopeWhereSlug(Builder $scope, string $slug): Builder 51 | { 52 | return $scope->where($this->getSlugKeyName(), $slug); 53 | } 54 | 55 | /** 56 | * Find a model by its primary slug. 57 | * 58 | * @return Collection|Model|static|static[]|null 59 | */ 60 | public static function findBySlug(string $slug, array $columns = ['*']) 61 | { 62 | return static::whereSlug($slug)->first($columns); 63 | } 64 | 65 | /** 66 | * Find a model by its primary slug or throw an exception. 67 | * 68 | * @return Model|static 69 | * 70 | * @throws ModelNotFoundException 71 | */ 72 | public static function findBySlugOrFail(string $slug, array $columns = ['*']) 73 | { 74 | return static::whereSlug($slug)->firstOrFail($columns); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cviebrock/eloquent-sluggable", 3 | "description": "Easy creation of slugs for your Eloquent models in Laravel", 4 | "keywords": [ 5 | "eloquent-sluggable", 6 | "eloquent", 7 | "sluggable", 8 | "laravel", 9 | "slug" 10 | ], 11 | "homepage": "https://github.com/cviebrock/eloquent-sluggable", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Colin Viebrock", 16 | "email": "colin@viebrock.ca" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.2", 21 | "cocur/slugify": "^4.3", 22 | "illuminate/config": "^12.0", 23 | "illuminate/database": "^12.0", 24 | "illuminate/support": "^12.0" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^3.65", 28 | "larastan/larastan": "^3.0", 29 | "mockery/mockery": "^1.4.4", 30 | "orchestra/testbench": "^10.0", 31 | "pestphp/pest": "^3.7", 32 | "phpstan/phpstan": "^2.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Cviebrock\\EloquentSluggable\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Cviebrock\\EloquentSluggable\\Tests\\": "tests" 42 | } 43 | }, 44 | "scripts": { 45 | "analyze": "vendor/bin/phpstan analyze", 46 | "fresh": [ 47 | "rm -rf vendor composer.lock", 48 | "composer install" 49 | ], 50 | "style:check": "vendor/bin/php-cs-fixer check -v", 51 | "style:fix": "vendor/bin/php-cs-fixer fix -v", 52 | "tests": [ 53 | "rm -rf build", 54 | "XDEBUG_MODE=coverage php vendor/bin/pest" 55 | ], 56 | "tests:ci": [ 57 | "vendor/bin/pest --teamcity" 58 | ] 59 | }, 60 | "extra": { 61 | "laravel": { 62 | "providers": [ 63 | "Cviebrock\\EloquentSluggable\\ServiceProvider" 64 | ] 65 | } 66 | }, 67 | "minimum-stability": "dev", 68 | "prefer-stable": true, 69 | "config": { 70 | "sort-packages": true, 71 | "allow-plugins": { 72 | "pestphp/pest-plugin": true 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__); 9 | 10 | return (new Config()) 11 | ->setParallelConfig(ParallelConfigFactory::detect()) 12 | ->setRules([ 13 | '@PhpCsFixer' => true, 14 | '@PHP84Migration' => true, 15 | 'indentation_type' => true, 16 | 17 | // Overrides for (opinionated) @PhpCsFixer and @Symfony rules: 18 | 19 | // Align "=>" in multi-line array definitions, unless a blank line exists between elements 20 | 'binary_operator_spaces' => ['operators' => ['=>' => 'align_single_space_minimal']], 21 | 22 | // Subset of statements that should be proceeded with blank line 23 | 'blank_line_before_statement' => ['statements' => ['case', 'continue', 'default', 'return', 'throw', 'try', 'yield', 'yield_from']], 24 | 25 | // Enforce space around concatenation operator 26 | 'concat_space' => ['spacing' => 'one'], 27 | 28 | // Use {} for empty loop bodies 29 | 'empty_loop_body' => ['style' => 'braces'], 30 | 31 | // Don't change any increment/decrement styles 32 | 'increment_style' => false, 33 | 34 | // Forbid multi-line whitespace before the closing semicolon 35 | 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'], 36 | 37 | // Clean up PHPDocs, but leave @inheritDoc entries alone 38 | 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'remove_inheritdoc' => false], 39 | 40 | // Ensure that traits are listed first in classes 41 | // (it would be nice to enforce more, but we'll start simple) 42 | 'ordered_class_elements' => ['order' => ['use_trait']], 43 | 44 | // Ensure that param and return types are sorted consistently, with null at end 45 | 'phpdoc_types_order' => ['sort_algorithm' => 'alpha', 'null_adjustment' => 'always_last'], 46 | 47 | // Don't add @coversNothing annotations to tests 48 | 'php_unit_test_class_requires_covers' => false, 49 | 50 | // Yoda style is too weird 51 | 'yoda_style' => false, 52 | ]) 53 | ->setIndent(' ') 54 | ->setLineEnding("\n") 55 | ->setFinder($finder); 56 | -------------------------------------------------------------------------------- /src/SluggableObserver.php: -------------------------------------------------------------------------------- 1 | slugService = $slugService; 36 | $this->events = $events; 37 | } 38 | 39 | /** 40 | * @return bool|void 41 | */ 42 | public function saving(Model $model) 43 | { 44 | // @phpstan-ignore-next-line 45 | if ($model->sluggableEvent() !== self::SAVING) { 46 | return; 47 | } 48 | 49 | $this->generateSlug($model, 'saving'); 50 | } 51 | 52 | /** 53 | * @return bool|void 54 | */ 55 | public function saved(Model $model) 56 | { 57 | // @phpstan-ignore-next-line 58 | if ($model->sluggableEvent() !== self::SAVED) { 59 | return; 60 | } 61 | if ($this->generateSlug($model, 'saved')) { 62 | return $model->saveQuietly(); 63 | } 64 | } 65 | 66 | protected function generateSlug(Model $model, string $event): bool 67 | { 68 | // If the "slugging" event returns false, abort 69 | if ($this->fireSluggingEvent($model, $event) === false) { 70 | return false; 71 | } 72 | $wasSlugged = $this->slugService->slug($model); 73 | 74 | $this->fireSluggedEvent($model, $wasSlugged); 75 | 76 | return $wasSlugged; 77 | } 78 | 79 | /** 80 | * Fire the namespaced validating event. 81 | */ 82 | protected function fireSluggingEvent(Model $model, string $event): ?bool 83 | { 84 | return $this->events->until('eloquent.slugging: ' . get_class($model), [$model, $event]); 85 | } 86 | 87 | /** 88 | * Fire the namespaced post-validation event. 89 | */ 90 | protected function fireSluggedEvent(Model $model, string $status): void 91 | { 92 | $this->events->dispatch('eloquent.slugged: ' . get_class($model), [$model, $status]); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via pull requests via 6 | [Github](https://github.com/cviebrock/eloquent-sluggable). 7 | 8 | 1. Fork the project. 9 | 2. Create your bugfix/feature branch and write your (well-commented) code. 10 | 3. Ensure you follow our coding style: 11 | - Run `composer run style:check` to check. 12 | - Run `composer run style:fix` to automagically fix styling errors. 13 | 4. Run basic static analysis on your code with `composer run analyze` and fix any errors. 14 | 5. Create unit tests for your code: 15 | - Run `composer install --dev` in the root directory to install required testing packages. 16 | - Add your test classes/methods to the `/tests/` directory. 17 | - Run `composer run tests` and make sure everything passes (new and old tests). 18 | 6. Updated any documentation (e.g. in `README.md`), if appropriate. 19 | 7. Commit your changes (and your tests) and push to your branch. 20 | 8. Create a new pull request against this package's `master` branch. 21 | 22 | 23 | ## Pull Requests 24 | 25 | - **Use the [PHP-CS-Fixer Coding Standard](https://cs.symfony.com/doc/ruleSets/PhpCsFixer.html).** 26 | The easiest way to apply the conventions is to run `composer run style:fix`. 27 | 28 | - **Run static analysis with [phpstan](https://phpstan.org).** 29 | The easiest way to check is with `composer run analyze`. Bonus points if you can bump up the 30 | analysis level in `phpstan.dist.neon`! 31 | 32 | - **Add tests!** Your pull request won't be accepted if it doesn't have tests. 33 | 34 | - **Document any change in behaviour.** Make sure the `README.md` and any other relevant 35 | documentation are kept up-to-date. 36 | 37 | - **Consider our release cycle.** We try to follow [SemVer v2.0.0](http://semver.org/). 38 | Randomly breaking public APIs is not an option. 39 | 40 | - **Create feature branches.** Don't ask us to pull from your master branch. 41 | 42 | - **One pull request per feature.** If you want to do more than one thing, send multiple pull requests. 43 | 44 | - **Send coherent history.** - Make sure each individual commit in your pull request is meaningful. 45 | If you had to make multiple intermediate commits while developing, please 46 | [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) 47 | before submitting. 48 | 49 | - Don't worry about updating `CHANGELOG.md` or `.semver`. The package administrator 50 | will handle updating those when new releases are created. 51 | 52 | 53 | **Thank you!** 54 | -------------------------------------------------------------------------------- /src/Sluggable.php: -------------------------------------------------------------------------------- 1 | slug($instance, true); 51 | 52 | return $instance; 53 | } 54 | 55 | /** 56 | * Return the event name that should be listened to for generating slugs. 57 | * 58 | * Can be one of: 59 | * - SluggableObserver::SAVING (to generate the slug before the model is saved) 60 | * - SluggableObserver::SAVED (to generate the slug after the model is saved) 61 | * 62 | * The second option is required if the primary key is to be part of the slug 63 | * source, as it won't be set during the "saving" event. 64 | */ 65 | public function sluggableEvent(): string 66 | { 67 | return SluggableObserver::SAVING; 68 | } 69 | 70 | /** 71 | * Query scope for finding "similar" slugs, used to determine uniqueness. 72 | */ 73 | public function scopeFindSimilarSlugs(Builder $query, string $attribute, array $config, string $slug): Builder 74 | { 75 | $separator = $config['separator']; 76 | 77 | return $query->where(function (Builder $q) use ($attribute, $slug, $separator) { 78 | $q->where($attribute, '=', $slug) 79 | ->orWhere($attribute, 'LIKE', $slug . $separator . '%'); 80 | }); 81 | } 82 | 83 | /** 84 | * Return the sluggable configuration array for this model. 85 | */ 86 | abstract public function sluggable(): array; 87 | 88 | /** 89 | * Optionally customize the cocur/slugify engine. 90 | */ 91 | public function customizeSlugEngine(Slugify $engine, string $attribute): Slugify 92 | { 93 | return $engine; 94 | } 95 | 96 | /** 97 | * Optionally add constraints to the query that determines uniqueness. 98 | */ 99 | public function scopeWithUniqueSlugConstraints( 100 | Builder $query, 101 | Model $model, 102 | string $attribute, 103 | array $config, 104 | string $slug 105 | ): Builder { 106 | return $query; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/UniqueTests.php: -------------------------------------------------------------------------------- 1 | 'A post title', 25 | ]); 26 | if ($i === 1) { 27 | self::assertEquals('a-post-title', $post->slug); 28 | } else { 29 | self::assertEquals('a-post-title-' . $i, $post->slug); 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Test uniqueness after deletion. 36 | */ 37 | public function testUniqueAfterDelete(): void 38 | { 39 | $post1 = Post::create([ 40 | 'title' => 'A post title', 41 | ]); 42 | self::assertEquals('a-post-title', $post1->slug); 43 | 44 | $post2 = Post::create([ 45 | 'title' => 'A post title', 46 | ]); 47 | self::assertEquals('a-post-title-2', $post2->slug); 48 | 49 | $post1->delete(); 50 | 51 | $post3 = Post::create([ 52 | 'title' => 'A post title', 53 | ]); 54 | self::assertEquals('a-post-title', $post3->slug); 55 | } 56 | 57 | /** 58 | * Test custom unique query scopes. 59 | */ 60 | public function testCustomUniqueQueryScope(): void 61 | { 62 | $authorBob = Author::create(['name' => 'Bob']); 63 | $authorPam = Author::create(['name' => 'Pam']); 64 | 65 | // Bob's first post 66 | $post = new PostWithUniqueSlugConstraints(['title' => 'My first post']); 67 | $post->author()->associate($authorBob); 68 | $post->save(); 69 | 70 | self::assertEquals('my-first-post', $post->slug); 71 | 72 | // Bob's second post with same title is made unique 73 | $post = new PostWithUniqueSlugConstraints(['title' => 'My first post']); 74 | $post->author()->associate($authorBob); 75 | $post->save(); 76 | 77 | self::assertEquals('my-first-post-2', $post->slug); 78 | 79 | // Pam's first post with same title is scoped to her 80 | $post = new PostWithUniqueSlugConstraints(['title' => 'My first post']); 81 | $post->author()->associate($authorPam); 82 | $post->save(); 83 | 84 | self::assertEquals('my-first-post', $post->slug); 85 | 86 | // Pam's second post with same title is scoped to her and made unique 87 | $post = new PostWithUniqueSlugConstraints(['title' => 'My first post']); 88 | $post->author()->associate($authorPam); 89 | $post->save(); 90 | 91 | self::assertEquals('my-first-post-2', $post->slug); 92 | } 93 | 94 | public function testIssue431(): void 95 | { 96 | $post1 = Post::create([ 97 | 'title' => 'A post title', 98 | ]); 99 | self::assertEquals('a-post-title', $post1->slug); 100 | 101 | $post2 = new Post(); 102 | $post2->title = 'A post title'; 103 | $post2->save(); 104 | self::assertEquals('a-post-title-2', $post2->slug); 105 | } 106 | 107 | /** 108 | * Test custom firstUniqueSuffix configuration. 109 | */ 110 | public function testFirstUniqueSuffix(): void 111 | { 112 | $post1 = PostWithFirstUniqueSuffix::create([ 113 | 'title' => 'A post title', 114 | ]); 115 | self::assertEquals('a-post-title', $post1->slug); 116 | 117 | $post2 = PostWithFirstUniqueSuffix::create([ 118 | 'title' => 'A post title', 119 | ]); 120 | self::assertEquals('a-post-title-42', $post2->slug); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/OnUpdateTests.php: -------------------------------------------------------------------------------- 1 | 'My First Post', 22 | ]); 23 | $post->save(); 24 | self::assertEquals('my-first-post', $post->slug); 25 | 26 | $post->update([ 27 | 'title' => 'A New Title', 28 | ]); 29 | self::assertEquals('my-first-post', $post->slug); 30 | } 31 | 32 | /** 33 | * Test that the slug is regenerated if the field is emptied manually. 34 | */ 35 | public function testSlugDoesChangeWhenEmptiedManually(): void 36 | { 37 | $post = Post::create([ 38 | 'title' => 'My First Post', 39 | ]); 40 | $post->save(); 41 | self::assertEquals('my-first-post', $post->slug); 42 | 43 | $post->slug = null; 44 | $post->update([ 45 | 'title' => 'A New Title', 46 | ]); 47 | self::assertEquals('a-new-title', $post->slug); 48 | } 49 | 50 | /** 51 | * Test that the slug is regenerated if onUpdate is true. 52 | */ 53 | public function testSlugDoesChangeWithOnUpdate(): void 54 | { 55 | $post = PostWithOnUpdate::create([ 56 | 'title' => 'My First Post', 57 | ]); 58 | $post->save(); 59 | self::assertEquals('my-first-post', $post->slug); 60 | 61 | $post->update([ 62 | 'title' => 'A New Title', 63 | ]); 64 | self::assertEquals('a-new-title', $post->slug); 65 | } 66 | 67 | /** 68 | * Test that the slug is not regenerated if onUpdate is true 69 | * but the source fields didn't change. 70 | */ 71 | public function testSlugDoesNotChangeIfSourceDoesNotChange(): void 72 | { 73 | $post = PostWithOnUpdate::create([ 74 | 'title' => 'My First Post', 75 | ]); 76 | $post->save(); 77 | self::assertEquals('my-first-post', $post->slug); 78 | 79 | $post->update([ 80 | 'subtitle' => 'A Subtitle', 81 | ]); 82 | self::assertEquals('my-first-post', $post->slug); 83 | } 84 | 85 | /** 86 | * Test that the slug is not regenerated if onUpdate is true 87 | * but the source fields didn't change, even with multiple 88 | * increments of the same slug. 89 | * 90 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/317 91 | */ 92 | public function testSlugDoesNotChangeIfSourceDoesNotChangeMultiple(): void 93 | { 94 | $data = [ 95 | 'title' => 'My First Post', 96 | ]; 97 | $post1 = PostWithOnUpdate::create($data); 98 | $post2 = PostWithOnUpdate::create($data); 99 | $post3 = PostWithOnUpdate::create($data); 100 | $post4 = PostWithOnUpdate::create($data); 101 | self::assertEquals('my-first-post-4', $post4->slug); 102 | 103 | $post4->update([ 104 | 'subtitle' => 'A Subtitle', 105 | ]); 106 | self::assertEquals('my-first-post-4', $post4->slug); 107 | } 108 | 109 | /** 110 | * Test that the slug isn't set to null if the source fields 111 | * not loaded in model. 112 | */ 113 | public function testSlugDoesNotChangeIfSourceNotProvidedInModel(): void 114 | { 115 | $post = Post::create([ 116 | 'title' => 'My First Post', 117 | ]); 118 | self::assertEquals('my-first-post', $post->slug); 119 | 120 | $post = Post::whereKey($post->id)->first(['id', 'subtitle']); 121 | $post->update([ 122 | 'subtitle' => 'A Subtitle', 123 | ]); 124 | 125 | $post = Post::findOrFail($post->id); 126 | self::assertEquals('my-first-post', $post->slug); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/ScopeHelperTests.php: -------------------------------------------------------------------------------- 1 | slugKeyName when set. 19 | */ 20 | public function testSlugKeyNameProperty(): void 21 | { 22 | $post = PostWithMultipleSlugsAndCustomSlugKey::create([ 23 | 'title' => 'A Post Title', 24 | 'subtitle' => 'A Post Subtitle', 25 | ]); 26 | 27 | self::assertEquals('dummy', $post->getSlugKeyName()); 28 | self::assertEquals('a.post.subtitle', $post->dummy); 29 | self::assertEquals('a.post.subtitle', $post->getSlugKey()); 30 | } 31 | 32 | /** 33 | * Test primary slug is set to first defined slug if $model->slugKeyName is not set. 34 | */ 35 | public function testFirstSlugAsFallback(): void 36 | { 37 | $post = PostWithMultipleSlugsAndHelperTrait::create([ 38 | 'title' => 'A Post Title', 39 | ]); 40 | 41 | self::assertEquals('slug', $post->getSlugKeyName()); 42 | self::assertEquals('a-post-title', $post->getSlugKey()); 43 | } 44 | 45 | /** 46 | * Test primary slug query scope. 47 | */ 48 | public function testQueryScope(): void 49 | { 50 | PostWithMultipleSlugsAndHelperTrait::create([ 51 | 'title' => 'A Post Title A', 52 | ]); 53 | 54 | $post = PostWithMultipleSlugsAndHelperTrait::create([ 55 | 'title' => 'A Post Title B', 56 | ]); 57 | 58 | PostWithMultipleSlugsAndHelperTrait::create([ 59 | 'title' => 'A Post Title C', 60 | ]); 61 | 62 | self::assertEquals( 63 | $post->getKey(), 64 | PostWithMultipleSlugsAndHelperTrait::whereSlug('a-post-title-b')->first()->getKey() 65 | ); 66 | } 67 | 68 | /** 69 | * Test finding a model by its primary slug. 70 | */ 71 | public function testFindBySlug(): void 72 | { 73 | PostWithMultipleSlugsAndHelperTrait::create([ 74 | 'title' => 'A Post Title A', 75 | ]); 76 | 77 | $post = PostWithMultipleSlugsAndHelperTrait::create([ 78 | 'title' => 'A Post Title B', 79 | ]); 80 | 81 | PostWithMultipleSlugsAndHelperTrait::create([ 82 | 'title' => 'A Post Title C', 83 | ]); 84 | 85 | self::assertEquals( 86 | $post->getKey(), 87 | PostWithMultipleSlugsAndHelperTrait::findBySlug('a-post-title-b')->getKey() 88 | ); 89 | } 90 | 91 | /** 92 | * Test finding a model by its primary slug fails if the slug does not exist. 93 | */ 94 | public function testFindBySlugReturnsNullForNoRecord(): void 95 | { 96 | self::assertNull(PostWithMultipleSlugsAndHelperTrait::findBySlug('not a real record')); 97 | } 98 | 99 | /** 100 | * Test finding a model by its primary slug throws an exception if the slug does not exist. 101 | */ 102 | public function testFindBySlugOrFail(): void 103 | { 104 | PostWithMultipleSlugsAndHelperTrait::create([ 105 | 'title' => 'A Post Title A', 106 | ]); 107 | 108 | $post = PostWithMultipleSlugsAndHelperTrait::create([ 109 | 'title' => 'A Post Title B', 110 | ]); 111 | 112 | PostWithMultipleSlugsAndHelperTrait::create([ 113 | 'title' => 'A Post Title C', 114 | ]); 115 | 116 | self::assertEquals( 117 | $post->getKey(), 118 | PostWithMultipleSlugsAndHelperTrait::findBySlugOrFail('a-post-title-b')->getKey() 119 | ); 120 | 121 | $this->expectException(ModelNotFoundException::class); 122 | 123 | PostWithMultipleSlugsAndHelperTrait::findBySlugOrFail('not a real record'); 124 | } 125 | 126 | /** 127 | * Test that getSlugKeyName() works with the short configuration syntax. 128 | */ 129 | public function testGetSlugKeyNameWithShortConfig(): void 130 | { 131 | $post = new PostShortConfigWithScopeHelpers(); 132 | self::assertEquals('slug_field', $post->getSlugKeyName()); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrading 2 | 3 | ## Upgrading from 4.2 to 4.3 4 | 5 | * The signature for `scopeFindSimilarSlugs()` dropped the unused `$model` parameter: 6 | ```diff 7 | - public function scopeFindSimilarSlugs(Builder $query, Model $model, $attribute, $config, $slug) 8 | + public function scopeFindSimilarSlugs(Builder $query, $attribute, $config, $slug) 9 | ``` 10 | If you use this scope in your application, then remove the first argument passed to the scope. 11 | 12 | 13 | - - - 14 | 15 | ## Upgrading from 3.x to 4.x 16 | 17 | ### Configuration Changes 18 | 19 | The configuration array has changed slightly between versions: 20 | 21 | * In your `config/sluggable.php` configuration file, remove the `save_to` 22 | parameter as it is no longer used. Rename `build_from` to `source`, and convert the other 23 | parameters from snake_case to lower camelCase (e.g. `include_trashed` -> `includeTrashed`). 24 | * Your models no longer need to implement `Cviebrock\EloquentSluggable\SluggableInterface`. 25 | * Your models should now use the trait `Cviebrock\EloquentSluggable\Sluggable` instead of 26 | `Cviebrock\EloquentSluggable\SluggableTrait`, which no longer exists. 27 | * Per-model configuration has been moved from a protect property into a protected method, and 28 | the configuration array is now keyed with the attribute field where the slug is stored (i.e. the 29 | previous value of the `save_to` configuration. 30 | * The service provider name has changed, so update the entry in your project's `config/app.php` 31 | from `Cviebrock\EloquentSluggable\SluggableServiceProvider::class` to 32 | `Cviebrock\EloquentSluggable\ServiceProvider::class`. 33 | 34 | #### Version 3.x Configuration Example: 35 | 36 | ```php 37 | use Cviebrock\EloquentSluggable\SluggableInterface; 38 | use Cviebrock\EloquentSluggable\SluggableTrait; 39 | use Illuminate\Database\Eloquent\Model; 40 | 41 | class Post extends Model implements SluggableInterface 42 | { 43 | use SluggableTrait; 44 | 45 | /** 46 | * Sluggable configuration. 47 | * 48 | * @var array 49 | */ 50 | protected $sluggable = [ 51 | 'build_from' => 'title', 52 | 'save_to' => 'slug', 53 | 'separator' => '-', 54 | 'include_trashed' => true, 55 | ]; 56 | } 57 | ``` 58 | 59 | #### Converted Version 4.x Example: 60 | 61 | ```php 62 | use Cviebrock\EloquentSluggable\Sluggable; 63 | use Illuminate\Database\Eloquent\Model; 64 | 65 | class Post extends Model 66 | { 67 | use Sluggable; 68 | 69 | /** 70 | * Sluggable configuration. 71 | * 72 | * @var array 73 | */ 74 | public function sluggable() { 75 | return [ 76 | 'slug' => [ 77 | 'source' => 'title', 78 | 'separator' => '-', 79 | 'includeTrashed' => true, 80 | ] 81 | ]; 82 | } 83 | } 84 | ``` 85 | 86 | ### Other Changes 87 | 88 | #### Artisan Command 89 | 90 | The `php artisan sluggable:table` command has been deprecated so you will need to make and run your own 91 | migrations if you need to add columns to your database tables to store slug values. 92 | 93 | #### Route Model Binding 94 | 95 | Route Model Binding has been removed from the package. You are encouraged to handle this yourself 96 | in the model's `getRouteKeyName` method, or in a `RootServiceProvider::boot` method as described in 97 | the [Laravel Documentation](https://laravel.com/docs/5.2/routing#route-model-binding). 98 | 99 | See [ROUTE-MODEL-BINDING.md](ROUTE-MODEL-BINDING.md) for details. 100 | 101 | #### Query Scopes 102 | 103 | Because the package now supports multiple slugs per model, the `findBySlug()` and other `findBy*` 104 | methods have been removed from the package by default, as has the `whereSlug()` query scope. You should 105 | just update your code to use standard Eloquent methods to find your models, specifying which 106 | fields to search by: 107 | 108 | ```php 109 | // OLD 110 | $posts = Post::whereSlug($input)->get(); 111 | $post = Post::findBySlug($input); 112 | $post = Post::findBySlugOrFail($input); 113 | $post = Post::findBySlugOrIdOrFail($input); 114 | 115 | // NEW 116 | $posts = Post::where('slug',$input)->get(); 117 | $post = Post::where('slug', $input)->first(); 118 | $post = Post::where('slug', $input)->firstOrFail(); 119 | $post = Post::where('slug', $input)->first() ?: Post::findOrFail((int)$input); 120 | ``` 121 | 122 | Alternatively, your model can use the `SluggableScopeHelpers` trait. 123 | See [SCOPE-HELPERS.md](SCOPE-HELPERS.md) for details. 124 | 125 | 126 | - - - 127 | 128 | Copyright (c) 2013 Colin Viebrock 129 | -------------------------------------------------------------------------------- /resources/config/sluggable.php: -------------------------------------------------------------------------------- 1 | name; 9 | * 10 | * Or it can be an array of fields, like ["name", "company"], which builds a slug from: 11 | * 12 | * $model->name . ' ' . $model->company; 13 | * 14 | * If you've defined custom getters in your model, you can use those too, 15 | * since Eloquent will call them when you request a custom attribute. 16 | * 17 | * Defaults to null, which uses the toString() method on your model. 18 | */ 19 | 20 | 'source' => null, 21 | 22 | /* 23 | * The maximum length of a generated slug. Defaults to "null", which means 24 | * no length restrictions are enforced. Set it to a positive integer if you 25 | * want to make sure your slugs aren't too long. 26 | */ 27 | 28 | 'maxLength' => null, 29 | 30 | /* 31 | * If you are setting a maximum length on your slugs, you may not want the 32 | * truncated string to split a word in half. The default setting of "true" 33 | * will ensure this, e.g. with a maxLength of 12: 34 | * 35 | * "my source string" -> "my-source" 36 | * 37 | * Setting it to "false" will simply truncate the generated slug at the 38 | * desired length, e.g.: 39 | * 40 | * "my source string" -> "my-source-st" 41 | */ 42 | 43 | 'maxLengthKeepWords' => true, 44 | 45 | /* 46 | * If left to "null", then use the cocur/slugify package to generate the slug 47 | * (with the separator defined below). 48 | * 49 | * Set this to a closure that accepts two parameters (string and separator) 50 | * to define a custom slugger. e.g.: 51 | * 52 | * 'method' => function( $string, $sep ) { 53 | * return preg_replace('/[^a-z]+/i', $sep, $string); 54 | * }, 55 | * 56 | * Otherwise, this will be treated as a callable to be used. e.g.: 57 | * 58 | * 'method' => array('Str','slug'), 59 | */ 60 | 61 | 'method' => null, 62 | 63 | // Separator to use when generating slugs. Defaults to a hyphen. 64 | 65 | 'separator' => '-', 66 | 67 | /* 68 | * Enforce uniqueness of slugs? Defaults to true. 69 | * If a generated slug already exists, an incremental numeric 70 | * value will be appended to the end until a unique slug is found. e.g.: 71 | * 72 | * my-slug 73 | * my-slug-1 74 | * my-slug-2 75 | */ 76 | 77 | 'unique' => true, 78 | 79 | /* 80 | * If you are enforcing unique slugs, the default is to add an 81 | * incremental value to the end of the base slug. Alternatively, you 82 | * can change this value to a closure that accepts three parameters: 83 | * the base slug, the separator, and a Collection of the other 84 | * "similar" slugs. The closure should return the new unique 85 | * suffix to append to the slug. 86 | */ 87 | 88 | 'uniqueSuffix' => null, 89 | 90 | /* 91 | * What is the first suffix to add to a slug to make it unique? 92 | * For the default method of adding incremental integers, we start 93 | * counting at 2, so the list of slugs would be, e.g.: 94 | * 95 | * - my-post 96 | * - my-post-2 97 | * - my-post-3 98 | */ 99 | 'firstUniqueSuffix' => 2, 100 | 101 | /* 102 | * Should we include the trashed items when generating a unique slug? 103 | * This only applies if the softDelete property is set for the Eloquent model. 104 | * If set to "false", then a new slug could duplicate one that exists on a trashed model. 105 | * If set to "true", then uniqueness is enforced across trashed and existing models. 106 | */ 107 | 108 | 'includeTrashed' => false, 109 | 110 | /* 111 | * An array of slug names that can never be used for this model, 112 | * e.g. to prevent collisions with existing routes or controller methods, etc.. 113 | * Defaults to null (i.e. no reserved names). 114 | * Can be a static array, e.g.: 115 | * 116 | * 'reserved' => array('add', 'delete'), 117 | * 118 | * or a closure that returns an array of reserved names. 119 | * If using a closure, it will accept one parameter: the model itself, and should 120 | * return an array of reserved names, or null. e.g. 121 | * 122 | * 'reserved' => function( Model $model) { 123 | * return $model->some_method_that_returns_an_array(); 124 | * } 125 | * 126 | * In the case of a slug that gets generated with one of these reserved names, 127 | * we will do: 128 | * 129 | * $slug .= $separator + "1" 130 | * 131 | * and continue from there. 132 | */ 133 | 134 | 'reserved' => null, 135 | 136 | /* 137 | * Whether to update the slug value when a model is being 138 | * re-saved (i.e. already exists). Defaults to false, which 139 | * means slugs are not updated. 140 | * 141 | * Be careful! If you are using slugs to generate URLs, then 142 | * updating your slug automatically might change your URLs which 143 | * is probably not a good idea from an SEO point of view. 144 | * Only set this to true if you understand the possible consequences. 145 | */ 146 | 147 | 'onUpdate' => false, 148 | 149 | /* 150 | * If the default slug engine of cocur/slugify is used, this array of 151 | * configuration options will be used when instantiating the engine. 152 | */ 153 | 'slugEngineOptions' => [], 154 | ]; 155 | -------------------------------------------------------------------------------- /src/Services/SlugService.php: -------------------------------------------------------------------------------- 1 | setModel($model); 30 | 31 | $attributes = []; 32 | 33 | foreach ($this->model->sluggable() as $attribute => $config) { 34 | if (is_numeric($attribute)) { 35 | $attribute = $config; 36 | $config = $this->getConfiguration(); 37 | } else { 38 | $config = $this->getConfiguration($config); 39 | } 40 | 41 | $slug = $this->buildSlug($attribute, $config, $force); 42 | 43 | if ($slug !== null) { 44 | $this->model->setAttribute($attribute, $slug); 45 | $attributes[] = $attribute; 46 | } 47 | } 48 | 49 | return $this->model->isDirty($attributes); 50 | } 51 | 52 | /** 53 | * Get the sluggable configuration for the current model, 54 | * including default values where not specified. 55 | */ 56 | public function getConfiguration(array $overrides = []): array 57 | { 58 | $defaultConfig = config('sluggable', []); 59 | 60 | return array_merge($defaultConfig, $overrides); 61 | } 62 | 63 | /** 64 | * Build the slug for the given attribute of the current model. 65 | */ 66 | public function buildSlug(string $attribute, array $config, bool $force = false): ?string 67 | { 68 | $slug = $this->model->getAttribute($attribute); 69 | 70 | if ($force || $this->needsSlugging($attribute, $config)) { 71 | $source = $this->getSlugSource($config['source']); 72 | 73 | if ($source || is_numeric($source)) { 74 | $slug = $this->generateSlug($source, $config, $attribute); 75 | $slug = $this->validateSlug($slug, $config, $attribute); 76 | $slug = $this->makeSlugUnique($slug, $config, $attribute); 77 | } 78 | } 79 | 80 | return $slug; 81 | } 82 | 83 | /** 84 | * Determines whether the model needs slugging. 85 | */ 86 | protected function needsSlugging(string $attribute, array $config): bool 87 | { 88 | $value = $this->model->getAttributeValue($attribute); 89 | 90 | if ( 91 | $config['onUpdate'] === true 92 | || $value === null 93 | || trim($value) === '' 94 | ) { 95 | return true; 96 | } 97 | 98 | if ($this->model->isDirty($attribute)) { 99 | return false; 100 | } 101 | 102 | return !$this->model->exists; 103 | } 104 | 105 | /** 106 | * Get the source string for the slug. 107 | * 108 | * @param mixed $from 109 | */ 110 | protected function getSlugSource($from): string 111 | { 112 | if (is_null($from)) { 113 | return $this->model->__toString(); 114 | } 115 | 116 | $sourceStrings = array_map(function ($key) { 117 | $value = data_get($this->model, $key, $this->model->getAttribute($key)); 118 | if (is_bool($value)) { 119 | $value = (int) $value; 120 | } 121 | 122 | return $value; 123 | }, (array) $from); 124 | 125 | return implode(' ', $sourceStrings); 126 | } 127 | 128 | /** 129 | * Generate a slug from the given source string. 130 | * 131 | * @throws \UnexpectedValueException 132 | */ 133 | protected function generateSlug(string $source, array $config, string $attribute): string 134 | { 135 | $separator = $config['separator']; 136 | $method = $config['method']; 137 | $maxLength = $config['maxLength']; 138 | $maxLengthKeepWords = $config['maxLengthKeepWords']; 139 | 140 | if ($method === null) { 141 | $slugEngine = $this->getSlugEngine($attribute, $config); 142 | $slug = $slugEngine->slugify($source, $separator); 143 | } elseif (is_callable($method)) { 144 | $slug = $method($source, $separator); 145 | } else { 146 | throw new \UnexpectedValueException('Sluggable "method" for ' . get_class($this->model) . ':' . $attribute . ' is not callable nor null.'); 147 | } 148 | 149 | $len = mb_strlen($slug); 150 | if (is_string($slug) && $maxLength && $len > $maxLength) { 151 | $reverseOffset = $maxLength - $len; 152 | $lastSeparatorPos = mb_strrpos($slug, $separator, $reverseOffset); 153 | if ($maxLengthKeepWords && $lastSeparatorPos !== false) { 154 | $slug = mb_substr($slug, 0, $lastSeparatorPos); 155 | } else { 156 | $slug = trim(mb_substr($slug, 0, $maxLength), $separator); 157 | } 158 | } 159 | 160 | return $slug; 161 | } 162 | 163 | /** 164 | * Return a class that has a `slugify()` method, used to convert 165 | * strings into slugs. 166 | */ 167 | protected function getSlugEngine(string $attribute, array $config): Slugify 168 | { 169 | static $slugEngines = []; 170 | 171 | $key = get_class($this->model) . '.' . $attribute; 172 | 173 | if (!array_key_exists($key, $slugEngines)) { 174 | $engine = new Slugify($config['slugEngineOptions']); 175 | $engine = $this->model->customizeSlugEngine($engine, $attribute); 176 | 177 | $slugEngines[$key] = $engine; 178 | } 179 | 180 | return $slugEngines[$key]; 181 | } 182 | 183 | /** 184 | * Checks that the given slug is not a reserved word. 185 | * 186 | * @throws \UnexpectedValueException 187 | */ 188 | protected function validateSlug(string $slug, array $config, string $attribute): string 189 | { 190 | $separator = $config['separator']; 191 | $reserved = $config['reserved']; 192 | 193 | if ($reserved === null) { 194 | return $slug; 195 | } 196 | 197 | // check for reserved names 198 | if ($reserved instanceof \Closure) { 199 | $reserved = $reserved($this->model); 200 | } 201 | 202 | if (is_array($reserved)) { 203 | if (in_array($slug, $reserved)) { 204 | $method = $config['uniqueSuffix']; 205 | $firstSuffix = $config['firstUniqueSuffix']; 206 | 207 | if ($method === null) { 208 | $suffix = $this->generateSuffix($slug, $separator, collect($reserved), $firstSuffix); 209 | } elseif (is_callable($method)) { 210 | $suffix = $method($slug, $separator, collect($reserved), $firstSuffix); 211 | } else { 212 | throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.'); 213 | } 214 | 215 | return $slug . $separator . $suffix; 216 | } 217 | 218 | return $slug; 219 | } 220 | 221 | throw new \UnexpectedValueException('Sluggable "reserved" for ' . get_class($this->model) . ':' . $attribute . ' is not null, an array, or a closure that returns null/array.'); 222 | } 223 | 224 | /** 225 | * Checks if the slug should be unique, and makes it so if needed. 226 | * 227 | * @throws \UnexpectedValueException 228 | */ 229 | protected function makeSlugUnique(string $slug, array $config, string $attribute): string 230 | { 231 | if (!$config['unique']) { 232 | return $slug; 233 | } 234 | 235 | $separator = $config['separator']; 236 | 237 | // find all models where the slug is like the current one 238 | $list = $this->getExistingSlugs($slug, $attribute, $config); 239 | 240 | // if ... 241 | // a) the list is empty, or 242 | // b) our slug isn't in the list 243 | // ... we are okay 244 | if ( 245 | $list->count() === 0 246 | || $list->contains($slug) === false 247 | ) { 248 | return $slug; 249 | } 250 | 251 | // if our slug is in the list, but 252 | // a) it's for our model, or 253 | // b) it looks like a suffixed version of our slug 254 | // ... we are also okay (use the current slug) 255 | if ($list->has($this->model->getKey())) { 256 | $currentSlug = $list->get($this->model->getKey()); 257 | 258 | if ( 259 | $currentSlug === $slug 260 | || !$slug || strpos($currentSlug, $slug) === 0 261 | ) { 262 | return $currentSlug; 263 | } 264 | } 265 | 266 | $method = $config['uniqueSuffix']; 267 | $firstSuffix = $config['firstUniqueSuffix']; 268 | 269 | if ($method === null) { 270 | $suffix = $this->generateSuffix($slug, $separator, $list, $firstSuffix); 271 | } elseif (is_callable($method)) { 272 | $suffix = $method($slug, $separator, $list, $firstSuffix); 273 | } else { 274 | throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.'); 275 | } 276 | 277 | return $slug . $separator . $suffix; 278 | } 279 | 280 | /** 281 | * Generate a unique suffix for the given slug (and list of existing, "similar" slugs. 282 | * 283 | * @param mixed $firstSuffix 284 | */ 285 | protected function generateSuffix(string $slug, string $separator, Collection $list, $firstSuffix): string 286 | { 287 | $len = strlen($slug . $separator); 288 | 289 | // If the slug already exists, but belongs to 290 | // our model, return the current suffix. 291 | if ($list->search($slug) === $this->model->getKey()) { 292 | $suffix = explode($separator, $slug); 293 | 294 | return end($suffix); 295 | } 296 | 297 | $list->transform(function ($value, $key) use ($len) { 298 | return (int) substr($value, $len); 299 | }); 300 | 301 | $max = $list->max(); 302 | 303 | // return one more than the largest value, 304 | // or return the first suffix the first time 305 | return (string) ($max === 0 ? $firstSuffix : $max + 1); 306 | } 307 | 308 | /** 309 | * Get all existing slugs that are similar to the given slug. 310 | */ 311 | protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection 312 | { 313 | $includeTrashed = $config['includeTrashed']; 314 | 315 | $query = $this->model->newQuery() 316 | ->findSimilarSlugs($attribute, $config, $slug); 317 | 318 | // use the model scope to find similar slugs 319 | $query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug); 320 | 321 | // include trashed models if required 322 | if ($includeTrashed && $this->usesSoftDeleting()) { 323 | $query->withTrashed(); 324 | } 325 | 326 | // get the list of all matching slugs 327 | $results = $query 328 | ->withoutEagerLoads() 329 | ->select([$attribute, $this->model->getQualifiedKeyName()]) 330 | ->get() 331 | ->toBase(); 332 | 333 | // key the results and return 334 | return $results->pluck($attribute, $this->model->getKeyName()); 335 | } 336 | 337 | /** 338 | * Does this model use softDeleting? 339 | */ 340 | protected function usesSoftDeleting(): bool 341 | { 342 | return method_exists($this->model, 'bootSoftDeletes'); 343 | } 344 | 345 | /** 346 | * Generate a unique slug for a given string. 347 | * 348 | * @throws \InvalidArgumentException 349 | * @throws \UnexpectedValueException 350 | */ 351 | public static function createSlug(Model|string $model, string $attribute, string $fromString, ?array $config = null): string 352 | { 353 | if (is_string($model)) { 354 | $model = new $model(); 355 | } 356 | 357 | $instance = (new static())->setModel($model); 358 | 359 | if ($config === null) { 360 | $config = Arr::get($model->sluggable(), $attribute); 361 | if ($config === null) { 362 | $modelClass = get_class($model); 363 | 364 | throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}."); 365 | } 366 | } 367 | 368 | $config = $instance->getConfiguration($config); 369 | 370 | $slug = $instance->generateSlug($fromString, $config, $attribute); 371 | $slug = $instance->validateSlug($slug, $config, $attribute); 372 | $slug = $instance->makeSlugUnique($slug, $config, $attribute); 373 | 374 | return $slug; 375 | } 376 | 377 | /** 378 | * @return $this 379 | */ 380 | public function setModel(Model $model): self 381 | { 382 | $this->model = $model; 383 | 384 | return $this; 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 12.0.0 - 26-Feb-2025 4 | 5 | - Added Laravel 12 support 6 | 7 | 8 | ## 11.0.1 - 28-Nov-2024 9 | 10 | - Support PHP 8.4 (#616, thanks @SupianIDz) 11 | - Notice that Lumen will be deprecated in the next major release 12 | 13 | 14 | ## 11.0.0 - 12-Mar-2024 15 | 16 | - Added Laravel 11 support (#608, thanks @fulopattila122) 17 | - Changed the minimum PHP version to 8.2 to line up with Laravel 11 18 | - fix for larastan issue (#596, thanks @WalrusSoup) 19 | 20 | 21 | ## 10.0.0 - 16-Feb-2023 22 | 23 | - Laravel 10.0 support 24 | - switch to using Pest instead of PHPUnit for tests 25 | 26 | 27 | ## 9.0.0 - 24-Jan-2022 28 | 29 | - Laravel 9 support (#577, #578, thanks @carbonvader and @tabcitu) 30 | 31 | 32 | ## 8.0.8 - 11-Jun-2021 33 | 34 | - fix event tests and `registerModelEvent()` hook (#556, #561, thanks @standaniels) 35 | 36 | 37 | ## 8.0.7 - 19-May-2021 38 | 39 | - fix issue with `SluggableObserver::SAVED` not always saving 40 | the model (#558, #560, thanks @llewellyn-kevin) 41 | 42 | 43 | ## 8.0.5 - 28-Feb-2021 44 | 45 | - started unique suffixes with "-2" instead of "-1" (#549, thanks @Tamim26061) 46 | - this can be adjusted via the `firstUniqueSuffix` config setting 47 | 48 | 49 | ## 8.0.4 - 20-Jan-2021 50 | 51 | - bug fix for #543#issuecomment-763391948 (thanks @dluague) 52 | 53 | 54 | ## 8.0.3 - 19-Jan-2021 55 | 56 | - add ability to slug models on Eloquent's `saved` event, rather than 57 | `saving` 58 | - this adds a few more SQL queries per save, but allows for the use of 59 | the primary key field in the `source` configuration (see #539 and #448) 60 | - default configuration remains the same, but might change in a future release 61 | - added base `customizeSlugEngine` and `scopeWithUniqueSlugConstraints` methods 62 | to the trait, to enforce type hinting and return values 63 | - NOTE: if you were using these methods in your models, you may need to ensure 64 | the method signatures match now 65 | - see #544 for more information, including what to do if you had custom 66 | methods in another trait 67 | - add `slugEngineOptions` configuration option (see #454, thanks @Adioz01) 68 | - move automated testing from travis-ci to Github actions (#534, thanks @cbl) 69 | - clean up some third-party tools and badges 70 | - clean up docblocks, return hints, and PHPUnit method calls 71 | 72 | 73 | ## 8.0.2 - 29-Nov-2020 74 | 75 | - support PHP 8.0 (#533, thanks @cbl) 76 | 77 | 78 | ## 8.0.1 - 28-Sep-2020 79 | 80 | - fix when manually setting a slug to a "falsy" value (#527, thanks @huiyang) 81 | 82 | 83 | ## 8.0.0 - 10-Sep-2020 84 | 85 | - Laravel 8 support 86 | 87 | 88 | ## 7.0.1 - 06-Apr-2020 89 | 90 | - fix to help support translatable slugs (using either spatie or Astrotomic package) (#506, thanks @GeoSot) 91 | 92 | 93 | ## 7.0.0 - 04-Mar-2020 94 | 95 | - Laravel 7.0 support 96 | 97 | 98 | ## 6.0.3 - 09-Feb-2020 99 | 100 | - bump [cocur/slugify](https://github.com/cocur/slugify) to `^4.0` 101 | 102 | 103 | ## 6.0.2 - 09-Oct-2019 104 | 105 | - fix for PHP 7.4 beta (#486, thanks @KamaZzw) 106 | 107 | 108 | ## 6.0.1 - 13-Sep-2019 109 | 110 | - fix for semantic versioning 111 | 112 | 113 | ## 6.0.0 - 03-Sep-2019 114 | 115 | - Laravel 6.0 support (note the package version will now follow the Laravel version) 116 | 117 | 118 | ## 4.8.0 - 28-Feb-2019 119 | 120 | - Laravel 5.8 support (#460, big thanks @tabuna) 121 | 122 | 123 | ## 4.7.0 - 24-Feb-2019 124 | 125 | - Fix slug getting set to `null` if model is updated with no source column loaded (#450, thanks @mylgeorge) 126 | 127 | 128 | ## 4.6.0 - 04-Sep-2018 129 | 130 | - Laravel 5.7 support 131 | 132 | 133 | ## 4.5.1 - 21-May-2018 134 | 135 | - Bump versions of package dependencies 136 | 137 | 138 | ## 4.5.0 - 10-Feb-2018 139 | 140 | - Laravel 5.6 support 141 | 142 | 143 | ## 4.4.1 - 04-Jan-2018 144 | 145 | - Better exception message when calling `SlugService::createSlug` with an invalid attribute (#402, thanks @lptn) 146 | - Prettier unit test output 147 | 148 | 149 | ## 4.4.0 - 12-Dec-2017 150 | 151 | - Make sure truncated slugs (due to maxLength) don't end in a separator (#398) 152 | - Add `maxLengthKeepWords` configuration option (#398) 153 | 154 | 155 | ## 4.3.0 - 31-Aug-2017 156 | 157 | - Laravel 5.5 support, including auto-registration 158 | - Bumped `cocur/slugify` to `^3.0` 159 | 160 | 161 | ## 4.2.5 - 31-Aug-2017 162 | 163 | - Fixing composer requirements to support Laravel 5.4 only 164 | 165 | 166 | ## 4.2.4 - 04-Jul-2017 167 | 168 | - Documentation change (#374, thanks @fico7489) 169 | 170 | 171 | ## 4.2.3 - 18-Apr-2017 172 | 173 | - Switch to allow extending the class (#356, thanks @haddowg) 174 | - Fix when adding suffixes to reserved slugs (#356, thanks @haddowg) 175 | 176 | 177 | ## 4.2.2 - 23-Mar-2017 178 | 179 | - Better handling of numeric and boolean slug sources (#351, thanks @arturock) 180 | 181 | 182 | ## 4.2.1 - 01-Feb-2017 183 | 184 | - Support Laravel 5.4 (#339, thanks @maddhatter) 185 | 186 | 187 | ## 4.1.2 - 09-Nov-2016 188 | 189 | - Fix in `getExistingSlugs` when using global scopes (#327) 190 | - Update `Cocur\Slugify` to `^2.3`. 191 | 192 | 193 | ## 4.1.1 - 12-Oct-2016 194 | 195 | - Fix for slugs updating when they don't need to, when using `onUpdate` with `unique` (#317) 196 | 197 | 198 | ## 4.1.0 - 14-Sep-2016 199 | 200 | - The goal of the 4.1.x releases will be to focus on support in Laravel 5.3, only providing support for 5.1/5.2 201 | where it is easy and doesn't affect performance significantly. 202 | - Drop support for PHP <5.6 and HHVM (no longer supported by Laravel 5.3); fixes test build process 203 | 204 | 205 | ## 4.0.4 - 13-Sep-2016 206 | 207 | - Fix `SluggableScopeHelpers` to work when using the short configuration syntax (#314). 208 | 209 | 210 | ## 4.0.3 - 15-Jul-2016 211 | 212 | - Added `$config` argument to `SlugService::createSlug` method for optionally overriding 213 | the configuration for a statically generated slug (#286). 214 | 215 | 216 | ## 4.0.2 - 17-Jun-2016 217 | 218 | - Added `SluggableScopeHelpers` trait which restores some of the scoping and query 219 | functionality of the 3.x version of the package (#280, thanks @unstoppablecarl and @Keoghan). 220 | - Added the `onUpdate` configuration option back to the package. 221 | - Updated the documentation to show usage of the `SluggableScopeHelpers` trait, and 222 | how to use route model binding with slugs. 223 | 224 | 225 | ## 4.0.1 - 13-Jun-2016 226 | 227 | - Fixed several bugs related to Laravel 5.1 and collections (#263, #274). 228 | 229 | 230 | ## 4.0.0 - 10-Jun-2016 231 | 232 | - Fix for Laravel 5.1 (#263 thanks @roshangautam and @andregaldino). 233 | - Update `Cocur\Slugify` to `^2.1` (#269 thanks @shadoWalker89). 234 | 235 | 236 | ## 4.0.0-beta - 01-Jun-2016 237 | 238 | - Major revision 239 | - Model configuration is now handled in a `sluggable()` method. 240 | on the model instead of a property, and configuration options are now camelCase 241 | - Ability to generate more than one slug per model. 242 | - Removed all `findBy...()` scope/methods (can't really be used when a model 243 | has multiple slugs ... plus the code is easy enough to implement in the model). 244 | - Removed `onUpdate` configuration option. If you want to re-generate a slug 245 | on update, then set the model's slug to `null` before saving. Otherwise, existing 246 | slugs will never be overwritten. 247 | - `createSlug()` is no longer a static method on the model, but is a public method 248 | on the _SlugService_ class, with a different method signature (see docs). 249 | - Removed artisan command to add slug column to tables. You will need to do this 250 | (pretty simple) task yourself now. 251 | - Several bug fixes. 252 | - See [UPGRADING.md](UPGRADING.md) for details. 253 | 254 | 255 | ## 3.1.4 - 03-Jan-2016 256 | 257 | - Compatible with Laravel 5.2 (by removing calls to composer from migrate command) 258 | 259 | 260 | ## 3.1.3 - 07-Dec-2015 261 | 262 | - Fix for PostgreSQL and `findBySlugOrId()` (#205 thanks @Jaspur) 263 | 264 | 265 | ## 3.1.2 - 07-Nov-2015 266 | 267 | - Fix some namespacing issues in docblocks (#195) 268 | - Streamline artisan migrate call (#191 thanks @ntzm) 269 | - Fix bug when using magic getters (#188 thanks @ChrisReid) 270 | - Add a static slug generator (#185 thanks @phroggyy) 271 | - Lots of PSR-2 fixes 272 | 273 | 274 | ## 3.1.1 - 26-Oct-2015 275 | 276 | - Fix missing class reference (#192) 277 | - Clean up migration code (#191 thanks @natzim) 278 | - Fix when using magic getters (#188 thanks @ChrisReid) 279 | 280 | 281 | ## 3.1.0 - 14-Oct-2015 282 | 283 | - Convert code-base to PSR-2 284 | - If the source is empty, then set the slug to `null` (#162 thanks @PallMallShow) 285 | - Ability to use a model's relations in the `build_from` configuration (#171 thanks @blaxxi) 286 | - Added `getSlugEngine()` method so that the Cocur\Slugify class can be configured 287 | - Updated the migration stub for Laravel 5.1's PSR-2 changes (#174 thanks @39digits) 288 | - Added `slugging` and `slugged` Eloquent model events 289 | - Fix for `findBySlugOrId()` methods when the slug is numeric (#161 thanks @canvural) 290 | - Add static method `Model::createSlug('some string')` (#185 thanks @phroggyy) 291 | 292 | 293 | ## 3.0.0 - 06-Jul-2015 294 | 295 | - Don't increment unique suffix if slug is unchanged (#108 thanks @kkiernan) 296 | 297 | 298 | ## 3.0.0-beta - 12-Jun-2015 299 | 300 | - Laravel 5.1 support (#141/#148 thanks @Keoghan, @Bouhnosaure) 301 | - Removed `use_cache` option and support 302 | - Use (Cocur\Slugify)[https://github.com/cocur/slugify] as default slugging method 303 | - Fix for `include_trashed` option not working for models that inherit the SoftDeletes trait (#136 thanks @ramirezd42) 304 | - Added `generateSuffix()` method so you could use different strategies other than integers for making incremental slugs (#129 thanks @EspadaV8) 305 | - Various scope and lookup fixes (thanks @xire28) 306 | 307 | 308 | ## 3.0.0-alpha - 11-Feb-2015 309 | 310 | - Laravel 5.0 support 311 | - Remove Ardent support and tests 312 | - Fix so that `max_length` option only applies to string slugs (#64 thanks @enzomaserati) 313 | 314 | 315 | ## 2.0.5 - 13-Nov-2014 316 | 317 | - Fixed `findBySlug()` to return a model and `getBySlug()` to return a collection (#72 thanks @jaewun and @Jono20202) 318 | - Fixed testbench version requirements (#87 thanks @hannesvdvreken) 319 | - Fixed so that `max_length` option only applies to string slugs (#64 thanks @enzomaserati) 320 | - Cleaned up some redundant code and documentation (thanks @hannesvdvreken, @Anahkiasen, @nimbol) 321 | 322 | 323 | ## 2.0.4 - 23-Sep-2014 324 | 325 | - Fixed softDelete behaviour and tests so Laravel 4.2 and earlier are supported (#56 thanks @hammat). 326 | - Fixed alias for `Illuminate\Support\Str` to prepare for Laravel 4.3/5.0 (#58 thanks @0xMatt). 327 | 328 | 329 | ## 2.0.3 - 17-Jul-2014 330 | 331 | - Don't allow slugs to be empty (#44 thanks @lfbittencourt). 332 | 333 | 334 | ## 2.0.2 - 19-Jun-2014 335 | 336 | - Add `getExistingSlugs()` method to trait (#36 thanks @neilcrookes). 337 | 338 | 339 | ## 2.0.1 - 13-May-2014 340 | 341 | - Fix issue where manually setting the slug field would be overwritten when updating the sluggable fields (#32 thanks @D1kz). 342 | 343 | 344 | ## 2.0.0 - 27-Apr-2014 345 | 346 | - See the [README](https://github.com/cviebrock/eloquent-sluggable/tree/master#upgrading) for all upgrading details. 347 | - Now uses traits, so PHP >= 5.4 is required. 348 | - Configuration and usage is _mostly_ backwards-compatible, although users of Ardent or anyone who force-builds slugs will need to make some changes. 349 | - Use Laravel's cache to speed up unique slug generation (and prevent duplicates in heavy-usage cases). 350 | 351 | 352 | ## 1.0.8 - 20-Feb-2014 353 | 354 | - Fix issue where replicated models couldn't forcibly be re-slugged (#20 thanks @trideout). 355 | 356 | 357 | ## 1.0.7 - 03-Dec-2013 358 | 359 | - Really fix issue #15 -- "not in object context" errors. Previous fix didn't work for PHP 5.3.x (thanks again @mayoz). 360 | 361 | 362 | ## 1.0.6 - 02-Dec-2013 363 | 364 | - Update composer requirements so that the package installs nicely with the upcoming Laravel 4.1. 365 | - Updated docs to show how to use package with [Ardent](http://github.com/laravelbook/ardent) models (thanks to @Flynsarmy for the pointers). 366 | 367 | 368 | ## 1.0.5 - 15-Nov-2013 369 | 370 | - Fix issues where slugs would alternate between "slug" and "slug-1" when `on_update` and `unique` are set (#14, #16, thanks @mikembm, @JoeChilds). 371 | - Make `isIncremented` method static to solve possible "not in object context" error (#15, thanks @mayoz). 372 | 373 | 374 | ## 1.0.4 - 05-Nov-2013 375 | 376 | - Unit testing ... woot! Building this revealed three new bugs: 377 | - Fixed bug where using the default `method` didn't take into account a custom `separator`. 378 | - Proper fix for issue #5. 379 | - `include_trashed` wasn't working because you can't read the protected `softDelete` property of the model. 380 | 381 | 382 | ## 1.0.3 - 04-Nov-2013 383 | 384 | - Fixed PHP warnings about uninitialized variable (#10, thanks @JoeChilds). 385 | 386 | 387 | ## 1.0.2 - 03-Nov-2013 388 | 389 | - Allow reverting to a "smaller" version of a similar slug (#5, thanks @alkin). 390 | - Better collection filtering to prevent duplicate slugs on similar long slugs (#3, #6, thanks @torkiljohnsen, @brandonkboswell). 391 | - `include_trashed` option to include soft-deleted models when checking for uniqueness (#8, thanks @slovenianGooner). 392 | - Fixed "undefined variable reserved" error (#9, thanks @altrim). 393 | 394 | 395 | ## 1.0.1 - 02-Jul-2013 396 | 397 | - `reserved` configuration option prevents generated slugs from being from a list of "reserved" names (e.g. colliding with routes, etc.) (#2, thanks @ceejayoz). 398 | 399 | 400 | ## 1.0.0 - 18-Jun-2013 401 | 402 | - First non-beta release. 403 | - `$sluggable` property of model switched back to static, maintains L3 compatability (thanks @orkhan). 404 | - Updated type hinting in `Sluggable::make()` to better handle extended models (#1, thanks @altrim). 405 | 406 | 407 | ## 1.0.0-beta - 11-Jun-2013 408 | 409 | - Initial beta release. 410 | -------------------------------------------------------------------------------- /tests/BaseTests.php: -------------------------------------------------------------------------------- 1 | 'My First Post', 45 | ]); 46 | self::assertEquals('my-first-post', $post->slug); 47 | } 48 | 49 | /** 50 | * Test basic slugging functionality using short configuration syntax. 51 | */ 52 | public function testShortConfig(): void 53 | { 54 | $post = PostShortConfig::create([ 55 | 'title' => 'My First Post', 56 | ]); 57 | self::assertEquals('my-first-post', $post->slug); 58 | } 59 | 60 | /** 61 | * Test that accented characters and other stuff is "fixed". 62 | */ 63 | public function testAccentedCharacters(): void 64 | { 65 | $post = Post::create([ 66 | 'title' => 'My Dinner With André & François', 67 | ]); 68 | self::assertEquals('my-dinner-with-andre-francois', $post->slug); 69 | } 70 | 71 | /** 72 | * Test building a slug from multiple attributes. 73 | */ 74 | public function testMultipleSource(): void 75 | { 76 | $post = PostWithMultipleSources::create([ 77 | 'title' => 'A Post Title', 78 | 'subtitle' => 'A Subtitle', 79 | ]); 80 | self::assertEquals('a-post-title-a-subtitle', $post->slug); 81 | } 82 | 83 | public function testLeadingTrailingSpaces(): void 84 | { 85 | $post = Post::create([ 86 | 'title' => "\tMy First Post \r\n", 87 | ]); 88 | self::assertEquals('my-first-post', $post->slug); 89 | } 90 | 91 | /** 92 | * Test building a slug using a custom method. 93 | */ 94 | public function testCustomMethod(): void 95 | { 96 | $post = PostWithCustomMethod::create([ 97 | 'title' => 'A Post Title', 98 | 'subtitle' => 'A Subtitle', 99 | ]); 100 | self::assertEquals('eltit-tsop-a', $post->slug); 101 | } 102 | 103 | /** 104 | * Test building a slug using a custom method. 105 | */ 106 | public function testCustomCallableMethod(): void 107 | { 108 | $post = PostWithCustomCallableMethod::create([ 109 | 'title' => 'A Post Title', 110 | 'subtitle' => 'A Subtitle', 111 | ]); 112 | self::assertEquals('eltit-tsop-a', $post->slug); 113 | } 114 | 115 | /** 116 | * Test building a slug using a custom suffix. 117 | */ 118 | public function testCustomSuffix(): void 119 | { 120 | for ($i = 1; $i <= 20; $i++) { 121 | $post = PostWithCustomSuffix::create([ 122 | 'title' => 'A Post Title', 123 | 'subtitle' => 'A Subtitle', 124 | ]); 125 | 126 | if ($i === 1) { 127 | self::assertEquals('a-post-title', $post->slug); 128 | } else { 129 | self::assertEquals('a-post-title-' . chr($i + 95), $post->slug); 130 | } 131 | } 132 | } 133 | 134 | /** 135 | * Test building a slug using the __toString method. 136 | */ 137 | public function testToStringMethod(): void 138 | { 139 | $post = PostWithNoSource::create([ 140 | 'title' => 'A Post Title', 141 | ]); 142 | self::assertEquals('a-post-title', $post->slug); 143 | } 144 | 145 | /** 146 | * Test using a custom separator. 147 | */ 148 | public function testCustomSeparator(): void 149 | { 150 | $post = PostWithCustomSeparator::create([ 151 | 'title' => 'A post title', 152 | ]); 153 | self::assertEquals('a.post.title', $post->slug); 154 | } 155 | 156 | /** 157 | * Test using reserved word blocking. 158 | */ 159 | public function testReservedWord(): void 160 | { 161 | $post = PostWithReservedSlug::create([ 162 | 'title' => 'Add', 163 | ]); 164 | self::assertEquals('add-2', $post->slug); 165 | } 166 | 167 | /** 168 | * Test when reverting to a shorter version of a similar slug. 169 | * 170 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/5 171 | */ 172 | public function testIssue5(): void 173 | { 174 | $post = Post::create([ 175 | 'title' => 'My first post', 176 | ]); 177 | self::assertEquals('my-first-post', $post->slug); 178 | 179 | $post->title = 'My first post rocks'; 180 | $post->slug = null; 181 | $post->save(); 182 | self::assertEquals('my-first-post-rocks', $post->slug); 183 | 184 | $post->title = 'My first post'; 185 | $post->slug = null; 186 | $post->save(); 187 | self::assertEquals('my-first-post', $post->slug); 188 | } 189 | 190 | /** 191 | * Test model replication. 192 | * 193 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/20 194 | */ 195 | public function testIssue20(): void 196 | { 197 | $post1 = Post::create([ 198 | 'title' => 'My first post', 199 | ]); 200 | self::assertEquals('my-first-post', $post1->slug); 201 | 202 | $post2 = $post1->replicate(); 203 | self::assertEquals('my-first-post-2', $post2->slug); 204 | } 205 | 206 | /** 207 | * Test that we don't try and slug models that don't implement Sluggable. 208 | */ 209 | public function testNonSluggableModels(): void 210 | { 211 | $post = PostNotSluggable::create([ 212 | 'title' => 'My First Post', 213 | ]); 214 | self::assertEquals(null, $post->slug); 215 | } 216 | 217 | /** 218 | * Test for max_length option. 219 | */ 220 | public function testMaxLength(): void 221 | { 222 | $post = PostWithMaxLength::create([ 223 | 'title' => 'A post with a really long title', 224 | ]); 225 | self::assertEquals('a-post', $post->slug); 226 | } 227 | 228 | /** 229 | * Test for max_length option with word splitting. 230 | */ 231 | public function testMaxLengthSplitWords(): void 232 | { 233 | $post = PostWithMaxLengthSplitWords::create([ 234 | 'title' => 'A post with a really long title', 235 | ]); 236 | self::assertEquals('a-post-wit', $post->slug); 237 | } 238 | 239 | /** 240 | * Test for max_length option with increments. 241 | */ 242 | public function testMaxLengthWithIncrements(): void 243 | { 244 | for ($i = 1; $i <= 20; $i++) { 245 | $post = PostWithMaxLength::create([ 246 | 'title' => 'A post with a really long title', 247 | ]); 248 | if ($i === 1) { 249 | self::assertEquals('a-post', $post->slug); 250 | } else { 251 | self::assertEquals('a-post-' . $i, $post->slug); 252 | } 253 | } 254 | } 255 | 256 | /** 257 | * Test for max_length option with increments and word splitting. 258 | */ 259 | public function testMaxLengthSplitWordsWithIncrements(): void 260 | { 261 | for ($i = 1; $i <= 20; $i++) { 262 | $post = PostWithMaxLengthSplitWords::create([ 263 | 'title' => 'A post with a really long title', 264 | ]); 265 | if ($i === 1) { 266 | self::assertEquals('a-post-wit', $post->slug); 267 | } else { 268 | self::assertEquals('a-post-wit-' . $i, $post->slug); 269 | } 270 | } 271 | } 272 | 273 | /** 274 | * Test for max_length option with a slug that might end in separator. 275 | */ 276 | public function testMaxLengthDoesNotEndInSeparator(): void 277 | { 278 | $post = PostWithMaxLengthSplitWords::create([ 279 | 'title' => 'It should work', 280 | ]); 281 | self::assertEquals('it-should', $post->slug); 282 | } 283 | 284 | /** 285 | * Test that models aren't slugged if the slug field is defined. 286 | * 287 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/32 288 | */ 289 | public function testDoesNotNeedSluggingWhenSlugIsSet(): void 290 | { 291 | $post = Post::create([ 292 | 'title' => 'My first post', 293 | 'slug' => 'custom-slug', 294 | ]); 295 | self::assertEquals('custom-slug', $post->slug); 296 | } 297 | 298 | /** 299 | * Test that models aren't *re*slugged if the slug field is defined. 300 | * 301 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/32 302 | */ 303 | public function testDoesNotNeedSluggingWithUpdateWhenSlugIsSet(): void 304 | { 305 | $post = Post::create([ 306 | 'title' => 'My first post', 307 | 'slug' => 'custom-slug', 308 | ]); 309 | self::assertEquals('custom-slug', $post->slug); 310 | 311 | $post->title = 'A New Title'; 312 | $post->save(); 313 | self::assertEquals('custom-slug', $post->slug); 314 | 315 | $post->title = 'A Another New Title'; 316 | $post->slug = 'new-custom-slug'; 317 | $post->save(); 318 | self::assertEquals('new-custom-slug', $post->slug); 319 | } 320 | 321 | /** 322 | * Test that models are still updated even if slug is not updated. 323 | * 324 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/559 325 | */ 326 | public function testModelStillSavesWhenSlugIsNotUpdated() 327 | { 328 | $post = Post::create([ 329 | 'title' => 'My Post', 330 | 'subtitle' => 'My First Subtitle', 331 | ]); 332 | 333 | self::assertEquals('my-post', $post->slug); 334 | 335 | $post->subtitle = 'My Second Subtitle'; 336 | $post->save(); 337 | $post->refresh(); 338 | 339 | self::assertEquals('my-post', $post->slug); 340 | self::assertEquals('My Second Subtitle', $post->subtitle); 341 | } 342 | 343 | /** 344 | * Test generating slug from related model field. 345 | */ 346 | public function testSlugFromRelatedModel(): void 347 | { 348 | $author = Author::create([ 349 | 'name' => 'Arthur Conan Doyle', 350 | ]); 351 | $post = new PostWithRelation([ 352 | 'title' => 'First', 353 | ]); 354 | $post->author()->associate($author); 355 | $post->save(); 356 | self::assertEquals('arthur-conan-doyle-first', $post->slug); 357 | } 358 | 359 | /** 360 | * Test generating slug when related model doesn't exists. 361 | */ 362 | public function testSlugFromRelatedModelNotExists(): void 363 | { 364 | $post = PostWithRelation::create([ 365 | 'title' => 'First', 366 | ]); 367 | self::assertEquals('first', $post->slug); 368 | } 369 | 370 | /** 371 | * Test that a null slug source creates a null slug. 372 | */ 373 | public function testNullSourceGeneratesEmptySlug(): void 374 | { 375 | $post = PostWithCustomSource::create([ 376 | 'title' => 'My Test Post', 377 | ]); 378 | self::assertEquals(null, $post->slug); 379 | } 380 | 381 | /** 382 | * Test that a zero length slug source creates a null slug. 383 | */ 384 | public function testZeroLengthSourceGeneratesEmptySlug(): void 385 | { 386 | $post = Post::create([ 387 | 'title' => '', 388 | ]); 389 | self::assertNull($post->slug); 390 | } 391 | 392 | /** 393 | * Test using custom Slugify rules. 394 | */ 395 | public function testCustomEngineRules(): void 396 | { 397 | $post = PostWithCustomEngine::create([ 398 | 'title' => 'The quick brown fox jumps over the lazy dog', 399 | ]); 400 | self::assertEquals('tha-qaack-brawn-fax-jamps-avar-tha-lazy-dag', $post->slug); 401 | } 402 | 403 | /** 404 | * Test using additional custom Slugify rules. 405 | */ 406 | public function testCustomEngineRules2(): void 407 | { 408 | $post = PostWithCustomEngine2::create([ 409 | 'title' => 'The quick brown fox/jumps over/the lazy dog', 410 | ]); 411 | self::assertEquals('the-quick-brown-fox/jumps-over/the-lazy-dog', $post->slug); 412 | } 413 | 414 | public function testCustomEngineOptions(): void 415 | { 416 | $post = PostWithCustomEngineOptions::create([ 417 | 'title' => 'My First Post', 418 | ]); 419 | self::assertEquals('My-First-Post', $post->slug); 420 | } 421 | 422 | /** 423 | * Test using a custom Slugify ruleset. 424 | */ 425 | public function testForeignRuleset(): void 426 | { 427 | $post = PostWithForeignRuleset::create([ 428 | 'title' => 'Mia unua poŝto', 429 | ]); 430 | self::assertEquals('mia-unua-posxto', $post->slug); 431 | } 432 | 433 | /** 434 | * Test using a custom Slugify ruleset. 435 | */ 436 | public function testForeignRuleset2(): void 437 | { 438 | $post = PostWithForeignRuleset2::create([ 439 | 'title' => 'Jyväskylä', 440 | ]); 441 | self::assertEquals('jyvaskyla', $post->slug); 442 | } 443 | 444 | /** 445 | * Test if using an empty separator works. 446 | * 447 | * @see https://github.com/cviebrock/eloquent-sluggable/issues/256 448 | */ 449 | public function testEmptySeparator(): void 450 | { 451 | $post = PostWithEmptySeparator::create([ 452 | 'title' => 'My Test Post', 453 | ]); 454 | self::assertEquals('mytestpost', $post->slug); 455 | } 456 | 457 | /** 458 | * Test models with multiple slug fields. 459 | */ 460 | public function testMultipleSlugs(): void 461 | { 462 | $post = PostWithMultipleSlugs::create([ 463 | 'title' => 'My Test Post', 464 | 'subtitle' => 'My Subtitle', 465 | ]); 466 | 467 | self::assertEquals('my-test-post', $post->slug); 468 | self::assertEquals('my.subtitle', $post->dummy); 469 | } 470 | 471 | /** 472 | * Test subscript characters in slug field. 473 | */ 474 | public function testSubscriptCharacters(): void 475 | { 476 | $post = Post::create([ 477 | 'title' => 'RDA-125-15/30/45m³/h CAV', 478 | ]); 479 | 480 | self::assertEquals('rda-125-15-30-45m3-h-cav', $post->slug); 481 | } 482 | 483 | /** 484 | * Test that a false-y string slug source creates a slug. 485 | */ 486 | public function testFalsyString(): void 487 | { 488 | $post = Post::create([ 489 | 'title' => '0', 490 | ]); 491 | self::assertEquals('0', $post->slug); 492 | } 493 | 494 | /** 495 | * Test that a false-y int slug source creates a slug. 496 | */ 497 | public function testFalsyInt(): void 498 | { 499 | $post = Post::create([ 500 | 'title' => 0, 501 | ]); 502 | self::assertEquals('0', $post->slug); 503 | } 504 | 505 | /** 506 | * Test that a boolean true source creates a slug. 507 | */ 508 | public function testTrueSource(): void 509 | { 510 | $post = Post::create([ 511 | 'title' => true, 512 | ]); 513 | self::assertEquals('1', $post->slug); 514 | } 515 | 516 | /** 517 | * Test that a boolean false slug source creates a slug. 518 | */ 519 | public function testFalseSource(): void 520 | { 521 | $post = Post::create([ 522 | 'title' => false, 523 | ]); 524 | self::assertEquals('0', $post->slug); 525 | } 526 | 527 | /** 528 | * Test that manually setting the slug to "0" doesn't 529 | * force a re-slugging. 530 | */ 531 | public function testIssue527(): void 532 | { 533 | $post = Post::create([ 534 | 'title' => 'example title', 535 | ]); 536 | self::assertEquals('example-title', $post->slug); 537 | 538 | $post->slug = '0'; 539 | $post->save(); 540 | self::assertEquals('0', $post->slug); 541 | 542 | $post->slug = ''; 543 | $post->save(); 544 | self::assertEquals('example-title', $post->slug); 545 | } 546 | 547 | /** 548 | * Test that you can use the model's primary key 549 | * as part of the source field when the sluggableEvent 550 | * is using the SAVED observer. 551 | */ 552 | public function testPrimaryKeyInSource(): void 553 | { 554 | $post = PostWithIdSourceOnSaved::create([ 555 | 'title' => 'My First Post', 556 | ]); 557 | self::assertEquals('my-first-post-1', $post->slug); 558 | 559 | $post2 = PostWithIdSourceOnSaved::create([ 560 | 'title' => 'My Second Post', 561 | ]); 562 | self::assertEquals('my-second-post-2', $post2->slug); 563 | 564 | $post->title = 'Still My First Post'; 565 | $post->save(); 566 | self::assertEquals('still-my-first-post-1', $post->slug); 567 | } 568 | 569 | /** 570 | * Test that when using the SAVED observer the slug is 571 | * actually persisted in storage. 572 | */ 573 | public function testOnSavedPersistsSlug() 574 | { 575 | $post = PostWithIdSourceOnSaved::create([ 576 | 'title' => 'My Test Post', 577 | ]); 578 | $post->refresh(); 579 | 580 | self::assertEquals('my-test-post-1', $post->slug); 581 | } 582 | 583 | /** 584 | * Test that you can't use the model's primary key 585 | * as part of the source field if the sluggableEvent 586 | * is the default SAVING. 587 | */ 588 | public function testPrimaryKeyInSourceOnSaving(): void 589 | { 590 | $post = PostWithIdSource::create([ 591 | 'title' => 'My First Post', 592 | ]); 593 | self::assertEquals('my-first-post', $post->slug); 594 | 595 | $post->title = 'Still My First Post'; 596 | $post->save(); 597 | 598 | self::assertEquals('still-my-first-post-1', $post->slug); 599 | } 600 | 601 | /** 602 | * Test building a slug using a custom method with array call. 603 | */ 604 | public function testCustomMethodArrayCall(): void 605 | { 606 | $post = PostWithCustomMethodArrayCall::create([ 607 | 'title' => 'A Post Title', 608 | 'subtitle' => 'A Subtitle', 609 | ]); 610 | self::assertEquals('eltit-tsop-a', $post->slug); 611 | } 612 | } 613 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eloquent-Sluggable 2 | 3 | Easy creation of slugs for your Eloquent models in Laravel. 4 | 5 | > [!NOTE] 6 | > These instructions are for the latest version of Laravel. 7 | > If you are using an older version, please install a version of the package 8 | > that [correlates to your Laravel version](#installation). 9 | 10 | [![Build Status](https://github.com/cviebrock/eloquent-sluggable/workflows/tests/badge.svg?branch=master)](https://github.com/cviebrock/eloquent-sluggable/actions) 11 | [![Total Downloads](https://poser.pugx.org/cviebrock/eloquent-sluggable/downloads?format=flat)](https://packagist.org/packages/cviebrock/eloquent-sluggable) 12 | [![Latest Stable Version](https://poser.pugx.org/cviebrock/eloquent-sluggable/v/stable?format=flat)](https://packagist.org/packages/cviebrock/eloquent-sluggable) 13 | [![Latest Unstable Version](https://poser.pugx.org/cviebrock/eloquent-sluggable/v/unstable?format=flat)](https://packagist.org/packages/cviebrock/eloquent-sluggable) 14 | [![SensioLabsInsight](https://insight.sensiolabs.com/projects/0b966e13-6a6a-4d17-bcea-61037f04cfe7/mini.png)](https://insight.sensiolabs.com/projects/0b966e13-6a6a-4d17-bcea-61037f04cfe7) 15 | [![License](https://img.shields.io/packagist/l/cviebrock/eloquent-sluggable)](LICENSE.md) 16 | 17 | 18 | - [Eloquent-Sluggable](#eloquent-sluggable) 19 | - [Background: What is a slug?](#background-what-is-a-slug) 20 | - [Installation](#installation) 21 | - [Updating your Eloquent Models](#updating-your-eloquent-models) 22 | - [Usage](#usage) 23 | - [The SlugService Class](#the-slugservice-class) 24 | - [When Is A Model Slugged?](#when-is-a-model-slugged) 25 | - [Events](#events) 26 | - [Configuration](#configuration) 27 | - [source](#source) 28 | - [method](#method) 29 | - [onUpdate](#onupdate) 30 | - [separator](#separator) 31 | - [unique](#unique) 32 | - [uniqueSuffix](#uniquesuffix) 33 | - [firstUniqueSuffix](#firstuniquesuffix) 34 | - [includeTrashed](#includetrashed) 35 | - [reserved](#reserved) 36 | - [maxLength](#maxlength) 37 | - [maxLengthKeepWords](#maxlengthkeepwords) 38 | - [slugEngineOptions](#slugengineoptions) 39 | - [Short Configuration](#short-configuration) 40 | - [Extending Sluggable](#extending-sluggable) 41 | - [customizeSlugEngine](#customizeslugengine) 42 | - [scopeWithUniqueSlugConstraints](#scopewithuniqueslugconstraints) 43 | - [scopeFindSimilarSlugs](#scopefindsimilarslugs) 44 | - [SluggableScopeHelpers Trait](#sluggablescopehelpers-trait) 45 | - [Route Model Binding](#route-model-binding) 46 | - [Bugs, Suggestions, Contributions and Support](#bugs-suggestions-contributions-and-support) 47 | - [Copyright and License](#copyright-and-license) 48 | 49 | 50 | ## Background: What is a slug? 51 | 52 | A slug is a simplified version of a string, typically URL-friendly. The act of "slugging" 53 | a string usually involves converting it to one case, and removing any non-URL-friendly 54 | characters (spaces, accented letters, ampersands, etc.). The resulting string can 55 | then be used as an identifier for a particular resource. 56 | 57 | For example, if you have a blog with posts, you could refer to each post via the ID: 58 | 59 | http://example.com/post/1 60 | http://example.com/post/2 61 | 62 | ... but that's not particularly friendly (especially for 63 | [SEO](http://en.wikipedia.org/wiki/Search_engine_optimization)). You probably would 64 | prefer to use the post's title in the URL, but that becomes a problem if your post 65 | is titled "My Dinner With André & François", because this is pretty ugly too: 66 | 67 | http://example.com/post/My+Dinner+With+Andr%C3%A9+%26+Fran%C3%A7ois 68 | 69 | The solution is to create a slug for the title and use that instead. You might want 70 | to use Laravel's built-in `Str::slug()` method to convert that title into something 71 | friendlier: 72 | 73 | http://example.com/post/my-dinner-with-andre-francois 74 | 75 | A URL like that will make users happier (it's readable, easier to type, etc.). 76 | 77 | For more information, you might want to read 78 | [this](http://en.wikipedia.org/wiki/Slug_(web_publishing)#Slug) description on Wikipedia. 79 | 80 | Slugs tend to be unique as well. So if you write another post with the same title, 81 | you'd want to distinguish between them somehow, typically with an incremental counter 82 | added to the end of the slug: 83 | 84 | http://example.com/post/my-dinner-with-andre-francois 85 | http://example.com/post/my-dinner-with-andre-francois-1 86 | http://example.com/post/my-dinner-with-andre-francois-2 87 | 88 | This keeps the URLs unique. 89 | 90 | The **Eloquent-Sluggable** package for Laravel aims to handle all of this for you 91 | automatically, with minimal configuration. 92 | 93 | 94 | ## Installation 95 | 96 | Depending on your version of Laravel, you should install a different 97 | version of the package. 98 | 99 | > [!NOTE] 100 | > As of version 6.0, the package's version should match the Laravel version. 101 | 102 | | Laravel Version | Package Version | 103 | |:---------------:|:---------------:| 104 | | 12.0 | ^12.0 | 105 | | 11.0 | ^11.0 | 106 | | 10.0 | ^10.0 | 107 | | 9.0 | ^9.0 | 108 | | 8.0 | ^8.0 | 109 | | 7.0 | ^7.0 | 110 | | 6.0 | ^6.0 | 111 | | 5.8 | 4.8.* | 112 | | 5.7 | 4.7.* | 113 | | 5.6 | 4.5.* | 114 | | 5.5 | 4.4.* | 115 | | 5.4 | 4.2.* | 116 | 117 | Older versions of Laravel can use older versions of the package, although they 118 | are no longer supported or maintained. See [CHANGELOG.md](CHANGELOG.md) and 119 | [UPGRADING.md](UPGRADING.md) for specifics, and be sure that you are reading 120 | the correct README.md for your version (GitHub displays the version in 121 | the _master_ branch by default, which might not be what you want). 122 | 123 | > [!WARNING] 124 | > Support for Laravel [Lumen](https://lumen.laravel.com/) will be dropped 125 | > in the 12.0 version of this package. 126 | 127 | 128 | 1. Install the package via Composer: 129 | 130 | ```sh 131 | composer require cviebrock/eloquent-sluggable 132 | ``` 133 | 134 | The package will automatically register its service provider. 135 | 136 | 2. Optionally, publish the configuration file if you want to change any defaults: 137 | 138 | ```sh 139 | php artisan vendor:publish --provider="Cviebrock\EloquentSluggable\ServiceProvider" 140 | ``` 141 | 142 | 143 | ## Updating your Eloquent Models 144 | 145 | Your models should use the Sluggable trait, which has an abstract method `sluggable()` 146 | that you need to define. This is where any model-specific configuration is set 147 | (see [Configuration](#configuration) below for details): 148 | 149 | ```php 150 | use Cviebrock\EloquentSluggable\Sluggable; 151 | 152 | class Post extends Model 153 | { 154 | use Sluggable; 155 | 156 | /** 157 | * Return the sluggable configuration array for this model. 158 | * 159 | * @return array 160 | */ 161 | public function sluggable(): array 162 | { 163 | return [ 164 | 'slug' => [ 165 | 'source' => 'title' 166 | ] 167 | ]; 168 | } 169 | } 170 | ``` 171 | 172 | Of course, your model and database will need a column in which to store the slug. 173 | You can use `slug` or any other appropriate name you want; your configuration array 174 | will determine to which field the data will be stored. You will need to add the 175 | column (which should be `NULLABLE`) manually via your own migration. 176 | 177 | That's it ... your model is now "sluggable"! 178 | 179 | 180 | 181 | ## Usage 182 | 183 | Saving a model is easy: 184 | 185 | ```php 186 | $post = Post::create([ 187 | 'title' => 'My Awesome Blog Post', 188 | ]); 189 | ``` 190 | 191 | So is retrieving the slug: 192 | 193 | ```php 194 | echo $post->slug; 195 | ``` 196 | 197 | > [!NOTE] 198 | > If you are replicating your models using Eloquent's `replicate()` method, 199 | > the package will automatically re-slug the model afterwards to ensure uniqueness. 200 | 201 | ```php 202 | $post = Post::create([ 203 | 'title' => 'My Awesome Blog Post', 204 | ]); 205 | // $post->slug is "my-awesome-blog-post" 206 | 207 | $newPost = $post->replicate(); 208 | // $newPost->slug is "my-awesome-blog-post-1" 209 | ``` 210 | 211 | > [!NOTE] 212 | > Empty strings, non-strings or other "odd" source values will result in different slugs: 213 | > 214 | > | Source Value | Resulting Slug | 215 | > |--------------|-----------------------| 216 | > | string | string | 217 | > | empty string | _no slug will be set_ | 218 | > | `null` | _no slug will be set_ | 219 | > | `0` | `"0"` | 220 | > | `1` | `"1"` | 221 | > | `false` | `"0"` | 222 | > | `true` | `"1"` | 223 | > 224 | > The above values would be subject to any unique or other checks as well. 225 | 226 | ## The SlugService Class 227 | 228 | All the logic to generate slugs is handled 229 | by the `\Cviebrock\EloquentSluggable\Services\SlugService` class. 230 | 231 | Generally, you don't need to access this class directly, although there is one 232 | static method that can be used to generate a slug for a given string without actually 233 | creating or saving an associated model. 234 | 235 | ```php 236 | use \Cviebrock\EloquentSluggable\Services\SlugService; 237 | 238 | $slug = SlugService::createSlug(Post::class, 'slug', 'My First Post'); 239 | ``` 240 | 241 | This would be useful for Ajax-y controllers or the like, where you want to show a 242 | user what the unique slug _would_ be for a given test input, before actually creating 243 | a model. The first two arguments to the method are the model and slug field being 244 | tested, and the third argument is the source string to use for testing the slug. 245 | 246 | You can also pass an optional array of configuration values as the fourth argument. 247 | These will take precedence over the normal configuration values for the slug field 248 | being tested. For example, if your model is configured to use unique slugs, but you 249 | want to generate the "base" version of a slug for some reason, you could do: 250 | 251 | ```php 252 | $slug = SlugService::createSlug(Post::class, 'slug', 'My First Post', ['unique' => false]); 253 | ``` 254 | 255 | 256 | 257 | ## When Is A Model Slugged? 258 | 259 | Currently, the model is slugged on Eloquent's `saving` event. 260 | This means that the slug is generated before any new data is 261 | written to the database. 262 | 263 | For new models, this means that the primary key has not yet been set, 264 | so it could not be used as part of the slug source, e.g.: 265 | 266 | ```php 267 | public function sluggable(): array 268 | { 269 | return [ 270 | 'slug' => [ 271 | 'source' => ['title', 'id'] 272 | ] 273 | ]; 274 | } 275 | ``` 276 | 277 | `$model->id` is `null` before the model is saved. The benefit of hooking into 278 | the `saving` event, however, is that we only needed to make one database 279 | query to save all the model's data, including the slug. 280 | 281 | Optional, the model can be slugged on Eloquent's `saved` event. 282 | This means that all the other model attributes will have already been 283 | persisted to the database and _are_ available for use as slug sources. 284 | So the above configuration would work. The only drawback is that 285 | saving the model to the database requires one extra query: the first one 286 | to save all the non-slug fields, and then a second one to update just 287 | the slug field. 288 | 289 | This behaviour is a breaking change, and likely won't affect most users 290 | (unless you are doing some pre-saving validation on a model's slug field). 291 | We feel the benefits outweigh the drawbacks, and so this will likely become 292 | the new default behaviour in a future major release of the package. 293 | Although, to make the transition easier, you can configure this behaviour 294 | via the `sluggableEvent` method the trait provides: 295 | 296 | ```php 297 | public function sluggableEvent(): string 298 | { 299 | /** 300 | * Default behaviour -- generate slug before model is saved. 301 | */ 302 | return SluggableObserver::SAVING; 303 | 304 | /** 305 | * Optional behaviour -- generate slug after model is saved. 306 | * This will likely become the new default in the next major release. 307 | */ 308 | return SluggableObserver::SAVED; 309 | } 310 | ``` 311 | 312 | Keep in mind that you will need to use `SluggableObserver::SAVED` if you want 313 | to use your model's primary key as part of the source fields for your slugs. 314 | 315 | 316 | 317 | ## Events 318 | 319 | Sluggable models will fire two Eloquent model events: "slugging" and "slugged". 320 | 321 | The "slugging" event is fired just before the slug is generated. If the callback 322 | from this event returns `false`, then the slugging is not performed. If anything 323 | else is returned, including `null`, then the slugging will be performed. 324 | 325 | The "slugged" event is fired just after a slug is generated. It won't be called 326 | in the case where the model doesn't need slugging (as determined by the `needsSlugging()` 327 | method). 328 | 329 | You can hook into either of these events just like any other Eloquent model event: 330 | 331 | ```php 332 | Post::registerModelEvent('slugging', static function($post) { 333 | if ($post->someCondition()) { 334 | // the model won't be slugged 335 | return false; 336 | } 337 | }); 338 | 339 | Post::registerModelEvent('slugged', static function($post) { 340 | Log::info('Post slugged: ' . $post->getSlug()); 341 | }); 342 | ``` 343 | 344 | 345 | 346 | ## Configuration 347 | 348 | Configuration was designed to be as flexible as possible. You can set up defaults 349 | for all of your Eloquent models, and then override those settings for individual 350 | models. 351 | 352 | By default, global configuration is set in the `config/sluggable.php` file. 353 | If a configuration isn't set, then the package defaults are used. 354 | Here is an example configuration, with all the default settings shown: 355 | 356 | ```php 357 | return [ 358 | 'source' => null, 359 | 'method' => null, 360 | 'onUpdate' => false, 361 | 'separator' => '-', 362 | 'unique' => true, 363 | 'uniqueSuffix' => null, 364 | 'firstUniqueSuffix' => 2, 365 | 'includeTrashed' => false, 366 | 'reserved' => null, 367 | 'maxLength' => null, 368 | 'maxLengthKeepWords' => true, 369 | 'slugEngineOptions' => [], 370 | ]; 371 | ``` 372 | 373 | For individual models, configuration is handled in the `sluggable()` method that you 374 | need to implement. That method should return an indexed array where the keys represent 375 | the fields where the slug value is stored and the values are the configuration for that 376 | field. This means you can create multiple slugs for the same model, based on different 377 | source strings and with different configuration options. 378 | 379 | ```php 380 | public function sluggable(): array 381 | { 382 | return [ 383 | 'title-slug' => [ 384 | 'source' => 'title' 385 | ], 386 | 'author-slug' => [ 387 | 'source' => ['author.lastname', 'author.firstname'], 388 | 'separator' => '_' 389 | ], 390 | ]; 391 | } 392 | ``` 393 | 394 | 395 | ### source 396 | 397 | This is the field or array of fields from which to build the slug. Each `$model->field` 398 | is concatenated (with space separation) to build the sluggable string. These can be 399 | model attributes (i.e. fields in the database), relationship attributes, or custom getters. 400 | 401 | To reference fields from related models, use dot-notation. For example, the 402 | slug for the following book will be generated from its author's name and the book's title: 403 | 404 | ```php 405 | class Book extends Eloquent 406 | { 407 | use Sluggable; 408 | 409 | protected $fillable = ['title']; 410 | 411 | public function sluggable(): array 412 | { 413 | return [ 414 | 'slug' => [ 415 | 'source' => ['author.name', 'title'] 416 | ] 417 | ]; 418 | } 419 | 420 | public function author(): \Illuminate\Database\Eloquent\Relations\BelongsTo 421 | { 422 | return $this->belongsTo(Author::class); 423 | } 424 | } 425 | ... 426 | class Author extends Eloquent 427 | { 428 | protected $fillable = ['name']; 429 | } 430 | ``` 431 | 432 | An example using a custom getter: 433 | 434 | ```php 435 | class Person extends Eloquent 436 | { 437 | use Sluggable; 438 | 439 | public function sluggable(): array 440 | { 441 | return [ 442 | 'slug' => [ 443 | 'source' => 'fullname' 444 | ] 445 | ]; 446 | } 447 | 448 | public function getFullnameAttribute(): string 449 | { 450 | return $this->firstname . ' ' . $this->lastname; 451 | } 452 | } 453 | ``` 454 | 455 | If `source` is empty, false or null, then the value of `$model->__toString()` is used 456 | as the source for slug generation. 457 | 458 | ### method 459 | 460 | Defines the method used to turn the sluggable string into a slug. There are three 461 | possible options for this configuration: 462 | 463 | 1. When `method` is null (the default setting), the package uses the default slugging 464 | engine -- [cocur/slugify](https://github.com/cocur/slugify) -- to create the slug. 465 | 466 | 2. When `method` is a callable, then that function or class method is used. The function/method 467 | should expect two parameters: the string to process, and a separator string. 468 | For example, to use Laravel's `Str::slug`, you could do: 469 | 470 | ```php 471 | 'method' => ['Illuminate\\Support\\Str', 'slug'], 472 | ``` 473 | 474 | 3. You can also define `method` as a closure (again, expecting two parameters): 475 | 476 | ```php 477 | 'method' => static function(string $string, string $separator): string { 478 | return strtolower(preg_replace('/[^a-z]+/i', $separator, $string)); 479 | }, 480 | ``` 481 | 482 | Any other values for `method` will throw an exception. 483 | 484 | For more complex slugging requirements, see [Extending Sluggable](#extending-sluggable) below. 485 | 486 | ### onUpdate 487 | 488 | By default, updating a model will not try and generate a new slug value. It is assumed 489 | that once your slug is generated, you won't want it to change (this may be especially 490 | true if you are using slugs for URLs and don't want to mess up your SEO mojo). 491 | 492 | If you want to regenerate one or more of your model's slug fields, you can set those 493 | fields to null or an empty string before the update: 494 | 495 | ```php 496 | $post->slug = null; 497 | $post->update(['title' => 'My New Title']); 498 | ``` 499 | 500 | If this is the behaviour you want every time you update a model, then set the `onUpdate` 501 | option to true. 502 | 503 | ### separator 504 | 505 | This defines the separator used when building a slug, and is passed to the `method` 506 | defined above. The default value is a hyphen. 507 | 508 | ### unique 509 | 510 | This is a boolean defining whether slugs should be unique among all models of the given type. 511 | For example, if you have two blog posts and both are called "My Blog Post", then they 512 | will both sluggify to "my-blog-post" if `unique` is false. This could be a problem, e.g. 513 | if you use the slug in URLs. 514 | 515 | By setting `unique` to true, then the second Post model will sluggify to "my-blog-post-1". 516 | If there is a third post with the same title, it will sluggify to "my-blog-post-2" 517 | and so on. Each subsequent model will get an incremental value appended to the end 518 | of the slug, ensuring uniqueness. 519 | 520 | ### uniqueSuffix 521 | 522 | If you want to use a different way of identifying uniqueness (other than auto-incrementing 523 | integers), you can set the `uniqueSuffix` configuration to a function or callable that 524 | generates the "unique" values for you. 525 | 526 | The function should take four parameters: 527 | 1. the base slug (i.e. the non-unique slug) 528 | 2. the separator string 529 | 3. an `\Illuminate\Support\Collection` of all the other slug strings that start with the same slug 530 | 4. the first suffix to use (for the first slug that needs to be made unique) 531 | You can then do whatever you want to create a new suffix that hasn't been used 532 | by any of the slugs in the collection. For example, if you wanted 533 | to use letters instead of numbers as a suffix, this is one way to achieve that: 534 | 535 | ```php 536 | 'uniqueSuffix' => static function(string $slug, string $separator, Collection $list, $firstSuffix): string 537 | { 538 | $size = count($list); 539 | 540 | return chr($size + 96); 541 | } 542 | ``` 543 | 544 | ### firstUniqueSuffix 545 | 546 | When adding a unique suffix, we start counting at "2", so that the list of 547 | generated slugs would look something like: 548 | - `my-unique-slug` 549 | - `my-unique-slug-2` 550 | - `my-unique-slug-3` 551 | - etc. 552 | 553 | If you want to start counting at a different number (or pass a different value 554 | into your custom `uniqueSuffix` function above), then you can define it here. 555 | 556 | > [!NOTE] 557 | > Prior versions of the package started with a unique suffix of `1`. 558 | > This was switched to `2` in version 8.0.5, as it's a more 559 | > "intuitive" suffix value to attach to the second slug. 560 | 561 | ### includeTrashed 562 | 563 | Setting this to `true` will also check deleted models when trying to enforce uniqueness. 564 | This only affects Eloquent models that are using the 565 | [softDelete](http://laravel.com/docs/eloquent#soft-deleting) feature. Default is `false`, 566 | so soft-deleted models don't count when checking for uniqueness. 567 | 568 | ### reserved 569 | 570 | An array of values that will never be allowed as slugs, e.g. to prevent collisions 571 | with existing routes or controller methods, etc.. This can be an array, or a closure 572 | that returns an array. Defaults to `null`: no reserved slug names. 573 | 574 | ### maxLength 575 | 576 | Setting this to a positive integer will ensure that your generated slugs are restricted 577 | to a maximum length (e.g. to ensure that they fit within your database fields). By default, 578 | this value is null and no limit is enforced. 579 | 580 | > [!NOTE] 581 | > If `unique` is enabled (which it is by default), and you anticipate having 582 | > several models with the same slug, then you should set this value to a few characters 583 | > less than the length of your database field. The reason why is that the class will 584 | > append "-2", "-3", "-4", etc., to subsequent models in order to maintain uniqueness. 585 | > These incremental extensions aren't included in part of the `maxLength` calculation. 586 | 587 | ### maxLengthKeepWords 588 | 589 | If you are truncating your slugs with the `maxLength` setting, than you probably 590 | want to ensure that your slugs don't get truncated in the middle of a word. For 591 | example, if your source string is "My First Post", and your `maxLength` is 10, 592 | the generated slug would end up being "my-first-p", which isn't ideal. 593 | 594 | By default, the `maxLengthKeepWords` value is set to true which would trim the 595 | partial words off the end of the slug, resulting in "my-first" instead of "my-first-p". 596 | 597 | If you want to keep partial words, then set this configuration to false. 598 | 599 | ### slugEngineOptions 600 | 601 | When `method` is null (the default setting), the package uses the default slugging 602 | engine -- [cocur/slugify](https://github.com/cocur/slugify) -- to create the slug. 603 | If you want to pass a custom set of options to the Slugify constructor when the engine 604 | is instantiated, this is where you would define that. 605 | See [the documentation](https://github.com/cocur/slugify#more-options) 606 | for Slugify for what those options are. Also, look at 607 | [customizeSlugEngine](#customizeslugengine) for other ways to customize Slugify 608 | for slugging. 609 | 610 | A common use for this is to turn on a different ruleset for a specific language. 611 | For example the string `Jyväskylä` will slug to `jyvaeskylae` using the default settings. 612 | In Finnish, it really should slug to `jyvaskyla`, so for that to work, you need to enable 613 | the Finnish ruleset for the attribute you are slugging: 614 | 615 | ```php 616 | public function sluggable(): array 617 | { 618 | return [ 619 | 'slug' => [ 620 | 'source' => 'title', 621 | 'slugEngineOptions' => [ 622 | 'ruleset' => 'finnish' 623 | ] 624 | ] 625 | ]; 626 | } 627 | ``` 628 | 629 | This can also be accomplished with the [customizeSlugEngine](#customizeslugengine) method 630 | (which, unless you add custom logic, will apply to _all_ attributes on the model): 631 | 632 | ```php 633 | public function customizeSlugEngine(Slugify $engine, string $attribute): \Cocur\Slugify\Slugify 634 | { 635 | $engine->activateRuleSet('finnish'); 636 | 637 | return $engine; 638 | } 639 | ``` 640 | 641 | ## Short Configuration 642 | 643 | The package supports a really short configuration syntax, if you are truly lazy: 644 | 645 | ```php 646 | public function sluggable(): array 647 | { 648 | return ['slug']; 649 | } 650 | ``` 651 | 652 | This will use all the default options from `config/sluggable.php`, use the model's 653 | `__toString()` method as the source, and store the slug in the `slug` field. 654 | 655 | 656 | 657 | ## Extending Sluggable 658 | 659 | Sometimes the configuration options aren't sufficient for complex needs (e.g. maybe 660 | the uniqueness test needs to take other attributes into account). 661 | 662 | In instances like these, the package offers hooks into the slugging workflow where you 663 | can use your own functions, either on a per-model basis, or in your own trait that extends 664 | the package's trait. 665 | 666 | > [!NOTE] 667 | > If you are putting these methods into your own trait, you will 668 | > need to indicate in your models that PHP should use _your_ trait methods 669 | > instead of the packages (since a class can't use two traits with the 670 | > same methods), e.g. 671 | > 672 | > ```php 673 | > /** 674 | > * Your trait where you collect your common Sluggable extension methods 675 | > */ 676 | > class MySluggableTrait { 677 | > public function customizeSlugEngine(...) {} 678 | > public function scopeWithUniqueSlugConstraints(...) {} 679 | > // etc. 680 | > } 681 | > 682 | > /** 683 | > * Your model 684 | > */ 685 | > class MyModel { 686 | > // Tell PHP to use your methods instead of the packages: 687 | > use Sluggable, 688 | > MySluggableTrait { 689 | > MySluggableTrait::customizeSlugEngine insteadof Sluggable; 690 | > MySluggableTrait::scopeWithUniqueSlugConstraints insteadof Sluggable; 691 | > } 692 | > 693 | > // ... 694 | > } 695 | > ``` 696 | 697 | 698 | ### customizeSlugEngine 699 | 700 | ```php 701 | /** 702 | * @param \Cocur\Slugify\Slugify $engine 703 | * @param string $attribute 704 | * @return \Cocur\Slugify\Slugify 705 | */ 706 | public function customizeSlugEngine(Slugify $engine, string $attribute): \Cocur\Slugify\Slugify 707 | { 708 | // ... 709 | return $engine; 710 | } 711 | ``` 712 | 713 | If you extend this method, the Slugify engine can be customized before slugging occurs. 714 | This might be where you change the character mappings that are used, or alter language files, etc.. 715 | 716 | You can customize the engine on a per-model and per-attribute basis (maybe your model has 717 | two slug fields, and one of them needs customization). 718 | 719 | Take a look at `tests/Models/PostWithCustomEngine.php` for an example. 720 | 721 | Also, take a look at the [slugEngineOptions](#slugengineoptions) 722 | configuration for other ways to customize Slugify. 723 | 724 | ### scopeWithUniqueSlugConstraints 725 | 726 | ```php 727 | /** 728 | * @param \Illuminate\Database\Eloquent\Builder $query 729 | * @param \Illuminate\Database\Eloquent\Model $model 730 | * @param string $attribute 731 | * @param array $config 732 | * @param string $slug 733 | * @return \Illuminate\Database\Eloquent\Builder 734 | */ 735 | public function scopeWithUniqueSlugConstraints( 736 | Builder $query, 737 | Model $model, 738 | string $attribute, 739 | array $config, 740 | string $slug 741 | ): Builder 742 | { 743 | // ... 744 | } 745 | ``` 746 | 747 | This method is applied to the query that is used to determine 748 | if a given slug is unique. The arguments passed to the scope are: 749 | 750 | * `$model` -- the object being slugged 751 | * `$attribute` -- the slug field being generated, 752 | * `$config` -- the configuration array for the given model and attribute 753 | * `$slug` -- the "base" slug (before any unique suffixes are applied) 754 | 755 | Feel free to use these values anyway you like in your query scope. As an example, look at 756 | `tests/Models/PostWithUniqueSlugConstraints.php` where the slug is generated for a post from 757 | its title, but the slug is scoped to the author. So Bob can have a post with the same title 758 | as Pam's post, but both will have the same slug. 759 | 760 | ### scopeFindSimilarSlugs 761 | 762 | ```php 763 | /** 764 | * Query scope for finding "similar" slugs, used to determine uniqueness. 765 | * 766 | * @param \Illuminate\Database\Eloquent\Builder $query 767 | * @param string $attribute 768 | * @param array $config 769 | * @param string $slug 770 | * @return \Illuminate\Database\Eloquent\Builder 771 | */ 772 | public function scopeFindSimilarSlugs(Builder $query, string $attribute, array $config, string $slug): Builder 773 | { 774 | // ... 775 | } 776 | ``` 777 | 778 | This is the default scope for finding "similar" slugs for a model. Basically, the package looks for existing 779 | slugs that are the same as the `$slug` argument, or that start with `$slug` plus the separator string. 780 | The resulting collection is what is passed to the `uniqueSuffix` handler. 781 | 782 | Generally, this query scope (which is defined in the Sluggable trait) should be left alone. 783 | However, you are free to overload it in your models. 784 | 785 | 786 | 787 | ## SluggableScopeHelpers Trait 788 | 789 | Adding the optional `SluggableScopeHelpers` trait to your model allows you to work with models 790 | and their slugs. For example: 791 | 792 | ```php 793 | $post = Post::whereSlug($slugString)->get(); 794 | 795 | $post = Post::findBySlug($slugString); 796 | 797 | $post = Post::findBySlugOrFail($slugString); 798 | ``` 799 | 800 | Because models can have more than one slug, this requires a bit more configuration. 801 | See [SCOPE-HELPERS.md](SCOPE-HELPERS.md) for all the details. 802 | 803 | 804 | 805 | ## Route Model Binding 806 | 807 | See [ROUTE-MODEL-BINDING.md](ROUTE-MODEL-BINDING.md) for details. 808 | 809 | 810 | 811 | ## Bugs, Suggestions, Contributions and Support 812 | 813 | Thanks to [everyone](https://github.com/cviebrock/eloquent-taggable/graphs/contributors) 814 | who has contributed to this project! 815 | 816 | Please use [GitHub](https://github.com/cviebrock/eloquent-sluggable) for reporting bugs, 817 | and making comments or suggestions. 818 | 819 | See [CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute changes. 820 | 821 | 822 | 823 | ## Copyright and License 824 | 825 | [eloquent-sluggable](https://github.com/cviebrock/eloquent-sluggable) 826 | was written by [Colin Viebrock](http://viebrock.ca) and is released under the 827 | [MIT License](LICENSE.md). 828 | 829 | Copyright (c) 2013 Colin Viebrock 830 | --------------------------------------------------------------------------------