├── .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 | [](https://github.com/cviebrock/eloquent-sluggable/actions)
11 | [](https://packagist.org/packages/cviebrock/eloquent-sluggable)
12 | [](https://packagist.org/packages/cviebrock/eloquent-sluggable)
13 | [](https://packagist.org/packages/cviebrock/eloquent-sluggable)
14 | [](https://insight.sensiolabs.com/projects/0b966e13-6a6a-4d17-bcea-61037f04cfe7)
15 | [](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 |
--------------------------------------------------------------------------------