Skip to content

Commit 361cc2a

Browse files
authored
Add uniqueCallback option to SluggedBehavior (#312)
Add a new `uniqueCallback` config option that allows customizing the uniqueness check when generating slugs. This is useful when other behaviors modify queries (e.g., multi-tenant scoping) and need to be temporarily disabled during the slug uniqueness check. The callback receives the Table instance and conditions array, and must return a boolean indicating whether a matching slug exists. Fixes #311
1 parent e283883 commit 361cc2a

3 files changed

Lines changed: 106 additions & 2 deletions

File tree

docs/Behavior/Slugged.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ A CakePHP behavior to automatically create and store slugs.
8383
</ul>
8484
</td>
8585
</tr>
86+
<tr>
87+
<td> uniqueCallback </td>
88+
<td> `null` </td>
89+
<td>
90+
A closure to customize the uniqueness check. Receives `(Table $table, array $conditions)` and must return `bool`.
91+
Useful when other behaviors modify queries (e.g., multi-tenant scoping) and you need to temporarily disable them during the uniqueness check.
92+
</td>
93+
</tr>
8694
<tr>
8795
<td> case </td>
8896
<td> `null` </td>
@@ -175,4 +183,21 @@ If you quickly want to find a record by its slug, use:
175183
```php
176184
->find()->find('slugged', slug: $slug)->firstOrFail();
177185
```
178-
etc
186+
187+
### Using a custom uniqueness callback
188+
If you have other behaviors that modify queries (e.g., multi-tenant scoping), you may need to
189+
temporarily disable them during the uniqueness check. Use the `uniqueCallback` option:
190+
191+
```php
192+
$this->addBehavior('Tools.Slugged', [
193+
'unique' => true,
194+
'uniqueCallback' => function (Table $table, array $conditions): bool {
195+
// Temporarily disable a scoping behavior
196+
$table->behaviors()->unload('TenantScope');
197+
$exists = $table->exists($conditions);
198+
$table->behaviors()->load('TenantScope');
199+
200+
return $exists;
201+
},
202+
]);
203+
```

src/Model/Behavior/SluggedBehavior.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Cake\ORM\Query\SelectQuery;
1111
use Cake\ORM\Table;
1212
use Cake\Utility\Inflector;
13+
use Closure;
1314
use RuntimeException;
1415
use Shim\Utility\Inflector as ShimInflector;
1516

@@ -69,6 +70,7 @@ class SluggedBehavior extends Behavior {
6970
* - on: beforeSave or beforeRules
7071
* - scope: certain conditions to use as scope
7172
* - tidy: If cleanup should be run on slugging
73+
* - uniqueCallback: A closure to customize the uniqueness check. Receives (Table $table, array $conditions) and must return bool.
7274
*
7375
* @var array<string, mixed>
7476
*/
@@ -81,6 +83,7 @@ class SluggedBehavior extends Behavior {
8183
'length' => null,
8284
'overwrite' => false,
8385
'unique' => false,
86+
'uniqueCallback' => null,
8487
'notices' => true,
8588
'case' => null,
8689
'replace' => [
@@ -426,7 +429,7 @@ public function generateSlug(string $value, ?EntityInterface $entity = null) {
426429
$i = 0;
427430
$suffix = '';
428431

429-
while ($this->_table->exists($conditions)) {
432+
while ($this->_slugExists($conditions)) {
430433
$i++;
431434
$suffix = $separator . $i;
432435
if ($this->_config['length'] && (mb_strlen($slug . $suffix) > $this->_config['length'])) {
@@ -442,6 +445,25 @@ public function generateSlug(string $value, ?EntityInterface $entity = null) {
442445
return $slug;
443446
}
444447

448+
/**
449+
* Check if a slug already exists.
450+
*
451+
* Uses the `uniqueCallback` closure if configured, otherwise falls back to
452+
* the default `exists()` check. This allows customizing the uniqueness check,
453+
* e.g. to temporarily disable other behaviors that might scope the query.
454+
*
455+
* @param array<string, mixed> $conditions The conditions to check for existence.
456+
* @return bool
457+
*/
458+
protected function _slugExists(array $conditions): bool {
459+
$callback = $this->_config['uniqueCallback'];
460+
if ($callback instanceof Closure) {
461+
return $callback($this->_table, $conditions);
462+
}
463+
464+
return $this->_table->exists($conditions);
465+
}
466+
445467
/**
446468
* Multi slug method
447469
*

tests/TestCase/Model/Behavior/SluggedBehaviorTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,63 @@ public function testSlugGenerationWithScope() {
650650
$this->assertEquals('Some-Article-12345', $result['slug']);
651651
}
652652

653+
/**
654+
* Test unique slug generation with custom callback.
655+
*
656+
* @return void
657+
*/
658+
public function testUniqueWithCallback() {
659+
$callbackInvocations = 0;
660+
661+
$this->articles->removeBehavior('Slugged');
662+
$this->articles->addBehavior('Tools.Slugged', [
663+
'unique' => true,
664+
'uniqueCallback' => function ($table, $conditions) use (&$callbackInvocations) {
665+
$callbackInvocations++;
666+
667+
return $table->exists($conditions);
668+
},
669+
]);
670+
671+
$article = $this->articles->newEntity(['title' => 'Callback Test']);
672+
$result = $this->articles->save($article);
673+
$this->assertTrue((bool)$result);
674+
$this->assertEquals('Callback-Test', $result['slug']);
675+
$this->assertSame(1, $callbackInvocations);
676+
677+
// Second article with same title should trigger multiple callback invocations
678+
$article2 = $this->articles->newEntity(['title' => 'Callback Test']);
679+
$result2 = $this->articles->save($article2);
680+
$this->assertTrue((bool)$result2);
681+
$this->assertEquals('Callback-Test-1', $result2['slug']);
682+
$this->assertSame(3, $callbackInvocations); // 1 initial + 2 more (first check + suffix check)
683+
}
684+
685+
/**
686+
* Test unique callback that always returns false (no duplicates).
687+
*
688+
* @return void
689+
*/
690+
public function testUniqueWithCallbackAlwaysFalse() {
691+
$this->articles->removeBehavior('Slugged');
692+
$this->articles->addBehavior('Tools.Slugged', [
693+
'unique' => true,
694+
'uniqueCallback' => function ($table, $conditions) {
695+
// Always return false = no duplicates found
696+
return false;
697+
},
698+
]);
699+
700+
$article = $this->articles->newEntity(['title' => 'Always Unique']);
701+
$result = $this->articles->save($article);
702+
$this->assertEquals('Always-Unique', $result['slug']);
703+
704+
// Even with same title, callback says no duplicate exists
705+
$article2 = $this->articles->newEntity(['title' => 'Always Unique']);
706+
$result2 = $this->articles->save($article2);
707+
$this->assertEquals('Always-Unique', $result2['slug']); // No suffix added
708+
}
709+
653710
/**
654711
* Test slug generation works with virtual fields.
655712
*

0 commit comments

Comments
 (0)