Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.11% covered (success)
92.11%
70 / 76
80.00% covered (warning)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SlugBehavior
92.11% covered (success)
92.11%
70 / 76
80.00% covered (warning)
80.00%
4 / 5
17.14
0.00% covered (danger)
0.00%
0 / 1
 initialize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 beforeSave
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 afterSave
82.35% covered (warning)
82.35%
28 / 34
0.00% covered (danger)
0.00%
0 / 1
7.27
 generateSlug
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validateUniqueSlug
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2declare(strict_types=1);
3
4namespace App\Model\Behavior;
5
6use ArrayObject;
7use Cake\Database\Expression\QueryExpression;
8use Cake\Datasource\EntityInterface;
9use Cake\Event\EventInterface;
10use Cake\I18n\DateTime;
11use Cake\ORM\Behavior;
12use Cake\ORM\Locator\LocatorAwareTrait;
13use Cake\Utility\Text;
14use Exception;
15
16/**
17 * Slug Behavior
18 *
19 * This behavior automatically generates and manages URL-friendly slugs for model entities.
20 * It maintains a history of slugs in a separate table and ensures slug uniqueness across the application.
21 *
22 * Features:
23 * - Customizable maximum slug length
24 * - Slug history tracking
25 * - Uniqueness validation across current and historical slugs
26 *
27 * @property \Cake\ORM\Table $table The table this behavior is attached to
28 */
29class SlugBehavior extends Behavior
30{
31    use LocatorAwareTrait;
32
33    /**
34     * Default configuration for the behavior
35     *
36     * @var array<string, mixed>
37     */
38    protected array $_defaultConfig = [
39        'sourceField' => 'title',
40        'targetField' => 'slug',
41        'maxLength' => 255,
42    ];
43
44    /**
45     * Initialize the behavior
46     *
47     * Sets up the hasMany relationship to Slugs table and adds slug uniqueness validation.
48     *
49     * @param array<string, mixed> $config The configuration settings provided to this behavior
50     * @return void
51     */
52    public function initialize(array $config): void
53    {
54        parent::initialize($config);
55
56        // Add the hasMany relationship to Slugs
57        $this->_table->hasMany('Slugs', [
58            'foreignKey' => 'foreign_key',
59            'conditions' => ['Slugs.model' => $this->_table->getAlias()],
60            'dependent' => true,
61        ]);
62
63        $this->_table->getValidator()->add($this->getConfig('targetField'), [
64            'unique' => [
65                'rule' => [$this, 'validateUniqueSlug'],
66                'message' => __('This slug is already in use.'),
67                'provider' => 'table',
68            ],
69        ]);
70    }
71
72    /**
73     * Before save callback
74     *
75     * Generates and sets the slug on the entity before saving if necessary.
76     *
77     * @param \Cake\Event\EventInterface $event The beforeSave event that was fired
78     * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
79     * @param \ArrayObject $options The options passed to the save method
80     * @return void
81     */
82    public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
83    {
84        $sourceField = $this->getConfig('sourceField');
85        $targetField = $this->getConfig('targetField');
86        $maxLength = $this->getConfig('maxLength');
87
88        if (!empty($entity->get($targetField))) {
89            $slug = $this->generateSlug($entity->get($targetField), $maxLength);
90            $entity->set($targetField, $slug);
91        } elseif (!empty($entity->get($sourceField))) {
92            $slug = $this->generateSlug($entity->get($sourceField), $maxLength);
93            $entity->set($targetField, $slug);
94        }
95    }
96
97    /**
98     * After save callback
99     *
100     * @param \Cake\Event\EventInterface $event The afterSave event that was fired
101     * @param \Cake\Datasource\EntityInterface $entity The entity that was saved
102     * @param \ArrayObject $options The options passed to the save method
103     * @return void
104     */
105    public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void
106    {
107        $targetField = $this->getConfig('targetField');
108        $slug = $entity->get($targetField);
109
110        if (empty($slug)) {
111            return;
112        }
113
114        $slugsTable = $this->fetchTable('Slugs');
115
116        // Check if the slug has actually changed
117        $original = $entity->getOriginal($targetField);
118
119        if (!$entity->isNew() && $original === $slug) {
120            return;
121        }
122
123        // Check if this slug is already in the history for this entity
124        $existingSlug = $slugsTable->find()
125            ->select(['Slugs.id'])
126            ->where([
127                'Slugs.model' => $this->_table->getAlias(),
128                'Slugs.foreign_key' => $entity->get($this->_table->getPrimaryKey()),
129                'Slugs.slug' => $slug,
130            ])
131            ->first();
132
133        if ($existingSlug === null) {
134            $slugData = [
135                'model' => $this->_table->getAlias(),
136                'foreign_key' => $entity->get($this->_table->getPrimaryKey()),
137                'slug' => $slug,
138                'created' => new DateTime(),
139            ];
140
141            try {
142                // Save slug history - use atomic false to avoid nested transaction conflicts
143                $slugEntity = $slugsTable->newEntity($slugData);
144                if (!$slugsTable->save($slugEntity, ['atomic' => false])) {
145                    $event->getSubject()->log(sprintf(
146                        'Failed to save slug history for slug: %s',
147                        $slug,
148                    ));
149                }
150            } catch (Exception $e) {
151                $event->getSubject()->log(sprintf(
152                    'Failed to save slug history: %s',
153                    $e->getMessage(),
154                ));
155            }
156        }
157    }
158
159    /**
160     * Generates a URL-safe slug from the given text
161     *
162     * @param string $text The text to convert into a slug
163     * @param int $maxLength The maximum length for the generated slug
164     * @return string The generated slug
165     */
166    protected function generateSlug(string $text, int $maxLength): string
167    {
168        $slug = Text::slug(strtolower($text), ['transliterator' => null]);
169
170        return substr($slug, 0, $maxLength);
171    }
172
173    /**
174     * Validates that a slug is unique across both the model table and slugs history
175     *
176     * @param mixed $value The slug value to check for uniqueness
177     * @param array<string, mixed> $context The validation context including the current entity data
178     * @return bool True if the slug is unique, false otherwise
179     */
180    public function validateUniqueSlug(mixed $value, array $context): bool
181    {
182        if (empty($value)) {
183            return true;
184        }
185
186        $targetField = $this->getConfig('targetField');
187
188        $conditions = [$targetField => $value];
189
190        if (!empty($context['data']['id'])) {
191            $conditions['id !='] = $context['data']['id'];
192        }
193
194        if ($this->_table->exists($conditions)) {
195            return false;
196        }
197
198        $slugsTable = $this->fetchTable('Slugs');
199
200        $slugConditions = [
201            'Slugs.slug' => $value,
202            'Slugs.model' => $this->_table->getAlias(),
203        ];
204
205        if (!empty($context['data']['id'])) {
206            $slugConditions[] = function (QueryExpression $exp) use ($context) {
207                return $exp->notEq('Slugs.foreign_key', $context['data']['id']);
208            };
209        }
210
211        return !$slugsTable->exists($slugConditions);
212    }
213}