Skip to content

Commit c3e7f7f

Browse files
authored
Merge pull request #289 from josepdecid/customizable-suffix
Allow to generate custom suffixes
2 parents 39e96e1 + 24d1980 commit c3e7f7f

File tree

4 files changed

+110
-6
lines changed

4 files changed

+110
-6
lines changed

README.md

+36-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ We highly appreciate you sending us a postcard from your hometown, mentioning wh
3030
## Installation
3131

3232
You can install the package via composer:
33-
``` bash
33+
34+
```bash
3435
composer require spatie/laravel-sluggable
3536
```
3637

@@ -281,7 +282,6 @@ public function getSlugOptions() : SlugOptions
281282

282283
### Using scopes
283284

284-
285285
If you have a global scope that should be taken into account, you can define this as well with `extraScope`. For example if you have a pages table containing pages of multiple websites and every website has it's own unique slugs.
286286

287287
```php
@@ -308,6 +308,40 @@ public function getSlugOptions() : SlugOptions
308308
}
309309
```
310310

311+
### Generating slug suffix on first occurrence
312+
313+
With the default behavior (assuming that we haven't disabled slug uniqueness with `allowDuplicateSlugs`), the generated slugs for two records with the same source values would be `this-is-an-example` and `this-is-an-example-1`.
314+
315+
When using this option, we are forcing the first occurence to also have a suffix so, even if the slug is unique as it is, it will be suffixed, resulting in `this-is-an-example-1` and `this-is-an-example-2`.
316+
317+
```php
318+
public function getSlugOptions() : SlugOptions
319+
{
320+
return SlugOptions::create()
321+
->generateSlugsFrom('name')
322+
->saveSlugsTo('slug')
323+
->useSuffixOnFirstOccurrence();
324+
}
325+
```
326+
327+
### Generating a custom slug suffix
328+
329+
By default, the mechanism to make slugs unique is to append an autoincremental value to the slug. You can generate a custom slug suffix such as a random string or hash with `usingSuffixGenerator`.
330+
331+
It accepts a callable that receives the base slug (without any suffix) and the iteration number, which represents how many times the suffix generation process has been run to ensure uniqueness. This number could be useful to monitor the collision rate of the generation process.
332+
333+
```php
334+
public function getSlugOptions() : SlugOptions
335+
{
336+
return SlugOptions::create()
337+
->generateSlugsFrom('name')
338+
->saveSlugsTo('slug')
339+
->usingSuffixGenerator(
340+
fn(string $slug, int $iteration) => bin2hex(random_bytes(4))
341+
); // Sample dummy method to generate a random hex code of length 8
342+
}
343+
```
344+
311345
### Integration with laravel-translatable
312346

313347
You can use this package along with [laravel-translatable](https://github.com/spatie/laravel-translatable) to generate a slug for each locale. Instead of using the `HasSlug` trait, you must use the `HasTranslatableSlug` trait, and add the name of the slug field to the `$translatable` array. For slugs that are generated from a single field _or_ multiple fields, you don't have to change anything else.
@@ -419,7 +453,6 @@ $model = Article::findBySlug('my-article');
419453

420454
`findBySlug` also accepts a second parameter `$columns` just like the default Eloquent `find` method.
421455

422-
423456
## Changelog
424457

425458
Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently.

src/HasSlug.php

+17-3
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,29 @@ protected function getSlugSourceStringFromCallable(): string
128128
protected function makeSlugUnique(string $slug): string
129129
{
130130
$originalSlug = $slug;
131-
$i = $this->slugOptions->startSlugSuffixFrom;
131+
$iteration = 0;
132132

133-
while ($this->otherRecordExistsWithSlug($slug) || $slug === '') {
134-
$slug = $originalSlug.$this->slugOptions->slugSeparator.$i++;
133+
while (
134+
$slug === '' ||
135+
$this->otherRecordExistsWithSlug($slug) ||
136+
($this->slugOptions->useSuffixOnFirstOccurrence && $iteration === 0)
137+
) {
138+
$suffix = $this->generateSuffix($originalSlug, $iteration++);
139+
$slug = $originalSlug . $this->slugOptions->slugSeparator . $suffix;
135140
}
136141

137142
return $slug;
138143
}
139144

145+
protected function generateSuffix(string $originalSlug, int $iteration): string
146+
{
147+
if ($this->slugOptions->suffixGenerator) {
148+
return call_user_func($this->slugOptions->suffixGenerator, $originalSlug, $iteration);
149+
}
150+
151+
return strval($this->slugOptions->startSlugSuffixFrom + $iteration);
152+
}
153+
140154
protected function otherRecordExistsWithSlug(string $slug): bool
141155
{
142156
$query = static::where($this->slugOptions->slugField, $slug)

src/SlugOptions.php

+23
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ class SlugOptions
1010
/** @var callable */
1111
public $extraScopeCallback;
1212

13+
/** @var (callable(string $slug, int $iteration): string)|null */
14+
public $suffixGenerator;
15+
1316
public string $slugField;
1417

1518
public bool $generateUniqueSlugs = true;
@@ -32,6 +35,8 @@ class SlugOptions
3235

3336
public int $startSlugSuffixFrom = 1;
3437

38+
public bool $useSuffixOnFirstOccurrence = false;
39+
3540
public static function create(): static
3641
{
3742
return new static();
@@ -133,4 +138,22 @@ public function startSlugSuffixFrom(int $startSlugSuffixFrom): self
133138

134139
return $this;
135140
}
141+
142+
public function useSuffixOnFirstOccurrence(): self
143+
{
144+
$this->useSuffixOnFirstOccurrence = true;
145+
146+
return $this;
147+
}
148+
149+
150+
/**
151+
* @param callable(string $slug, int $iteration): string $generator
152+
*/
153+
public function usingSuffixGenerator(callable $generator): self
154+
{
155+
$this->suffixGenerator = $generator;
156+
157+
return $this;
158+
}
136159
}

tests/HasSlugTest.php

+34
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,40 @@ public function getSlugOptions(): SlugOptions
335335
expect($replica->url)->toEqual('this-is-a-test-2');
336336
});
337337

338+
it('can generate slug suffix on first occurrence', function () {
339+
$model = new class () extends TestModel {
340+
public function getSlugOptions(): SlugOptions
341+
{
342+
return parent::getSlugOptions()->useSuffixOnFirstOccurrence();
343+
}
344+
};
345+
346+
$model->name = 'this is a test';
347+
$model->save();
348+
349+
expect($model->url)->toEqual('this-is-a-test-1');
350+
});
351+
352+
it('can generate a custom slug suffix using a callable', function () {
353+
$model = new class () extends TestModel {
354+
public function getSlugOptions(): SlugOptions
355+
{
356+
return parent::getSlugOptions()->usingSuffixGenerator(
357+
fn(string $slug, int $iteration) => 'random-with-access-base-slug-(' . $slug[0] . '_' . $iteration . ')'
358+
);
359+
}
360+
};
361+
362+
$model->name = 'this is a test';
363+
$model->save();
364+
365+
$replica = $model->replicate();
366+
$replica->save();
367+
368+
expect($model->url)->toEqual('this-is-a-test');
369+
expect($replica->url)->toEqual('this-is-a-test-random-with-access-base-slug-(t_0)');
370+
});
371+
338372
it('can find models using findBySlug alias', function () {
339373
$model = new class () extends TestModel {
340374
public function getSlugOptions(): SlugOptions

0 commit comments

Comments
 (0)