This post covers refactoring code related to the CakePHP Tree Behavior and slugs in Willow CMS. Originally, both Articles and Tags had duplicated code for tree operations. This was refactored into the OrderableBehavior, making the code reusable and testable. Similarly, slug management, previously scattered across multiple locations, was consolidated into the new SlugBehavior. This behavior handles slug generation, validation, and history, simplifying the ArticlesTable and TagsTable classes while adding features like automatic slug generation from titles, custom slugs, and slug history tracking for better SEO. This refactoring significantly reduced code duplication and improved maintainability and testability.
Refactoring Models Using the CakePHP Tree Behavior
Both Articles and Tags make use of the CakePHP Tree Behaviour. The Willow admin area allows drag and drop re-ordering of Pages and Tags (we’ll look at how to refactor the controller and view layer code for that in future), which meant both ArticlesTable
and TagsTable
classes had similar methods to get and modify hiearchical data.
Take a look at the old source for the old ArticlesTable (740 lines inc. coments) and the old TagsTable (430 lines).
Both had a very similar getPageTree
method to get a threaded array of data used for a hierarchical view of records.
public function getPageTree(array $additionalConditions = []): array
{
$conditions = [
'Articles.kind' => 'page',
];
// Merge the default conditions with any additional conditions provided
$conditions = array_merge($conditions, $additionalConditions);
$cacheKey = hash('xxh3', json_encode($conditions));
$query = $this->find()
->select([
'id',
'parent_id',
'title',
'slug',
'created',
'modified',
'is_published',
'pageview_count' => $this->PageViews->find()
->where(['PageViews.article_id = Articles.id'])
->select([
'count' => $this->PageViews->find()->func()->count('PageViews.id'),
]),
])
->where($conditions)
->orderBy(['lft' => 'ASC'])
->cache($cacheKey . 'article_page_tree', 'articles');
return $query->find('threaded')->toArray();
}
Both had a very similar reorder
method to modify the hierarchy via drag n drop.
public function reorder(array $data): bool
{
if (!is_array($data)) {
throw new InvalidArgumentException('Data must be an array');
}
$article = $this->get($data['id']);
if ($data['newParentId'] === 'root') {
// Moving to root level
$article->parent_id = null;
$this->save($article);
} else {
// Moving to a new parent
$newParent = $this->get($data['newParentId']);
$article->parent_id = $newParent->id;
$this->save($article);
}
// Adjust the position within siblings
if ($article->parent_id === null) {
// For root level items
$siblings = $this->find()
->where(['parent_id IS' => null])
->orderBy(['lft' => 'ASC'])
->toArray();
} else {
// For non-root items
$siblings = $this->find('children', for: $article->parent_id, direct: true)
->orderBy(['lft' => 'ASC'])
->toArray();
}
$currentPosition = array_search($article->id, array_column($siblings, 'id'));
$newPosition = $data['newIndex'];
if ($currentPosition !== false && $currentPosition !== $newPosition) {
if ($newPosition > $currentPosition) {
$this->moveDown($article, $newPosition - $currentPosition);
} else {
$this->moveUp($article, $currentPosition - $newPosition);
}
}
return true;
}
Both had code to add the Tree Behavior, which the code above can’t work without.
$this->addBehavior('Tree');
That’s about 80 lines of code duplicated between the ArticlesTable and TagsTable. The first step to refactoring this code should be to move it into a CakePHP Behavor and make it work with any model it is attached to. So I created the OrderableBehavior.
Now, neither model contains any of the code above and there are some nice ways to use the behavior. Here’s some examples from within a Controller context.
// Basic usage
$tree = $this->Articles->getTree();
// With additional conditions
$tree = $this->Articles->getTree(['is_published' => true]);
// With additional fields
$tree = $this->Articles->getTree([], ['created', 'modified', 'slug']);
// With both conditions and fields
$tree = $this->Articles->getTree(
['is_published' => true],
['created', 'modified', 'slug']
);
There are some interesting things the behavior is doing such as adding the CakePHP Tree Behavior onto the model automatically in the initialize
method.
public function initialize(array $config): void
{
parent::initialize($config);
// Add the Tree behavior if it's not already added
if (!$this->_table->hasBehavior('Tree')) {
$this->_table->addBehavior('Tree', $treeConfig);
}
}
This refactoring of code into a behavior has made it much easier to test the reordering functionality. Take a look at the specific test case written for the behavior here.
Refactoring Slugs for Use with Articles and Tags
I had done some basic work to keep Slug logic in one place with a Behavior. Take a look at the source for the old SluggableBehavior. It used a beforeSave
callback to trigger some basic slug generation logic but was also mixing in some validation error message setting.
This is a bit messy, mixing generating a slug with validating if it is unique all in the callback:
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): ?bool
{
$config = $this->getConfig();
$field = $config['field'];
$slugField = $config['slug'];
if ($entity->isNew() && !$entity->get($slugField)) {
$sluggedTitle = strtolower(Text::slug($entity->get($field)));
// trim slug to maximum length defined in schema
$entity->set($slugField, substr($sluggedTitle, 0, $config['maxLength']));
//check generated slug is unique
$existing = $this->table()->find()->where([$slugField => $entity->get($slugField)])->first();
if ($existing) {
// If not unique, set the slug back to the entity for user modification
$entity->setError($slugField, 'The generated slug is not unique. Please modify it.');
return false; // Prevent save
}
}
return true;
}
One good idea was that it used a buildValidator
callback to add validation rules for the slug
field to the model. The not so good was that I also had slug validation code in the ArticlesTable
, and there was quite a log of it for rules to validate:
- The length of the slug
- That the slug was unique in the Articles table (which would need to be repeated for Tags and any other model using the old behavior)
- That the slug was unique in the Slugs table (used as a slug history to maintain nice 301 redirects for SEO purposes)
$validator
->scalar('slug')
->maxLength('slug', 255) // Checking the length of the slug
->regex(
'slug',
'/^[a-z0-9-]+$/',
__('The slug must be URL-safe (only lowercase letters, numbers, and hyphens)')
)
->requirePresence('slug', 'create')
->notEmptyString('slug')
->add('slug', 'uniqueInArticles', [ // adding rules to make sure slug is unique in the Articles table (would need to be repeated in TagsTable model too)
'rule' => function ($value, $context) {
$exists = $this->exists(['slug' => $value]);
if ($exists && isset($context['data']['id'])) {
$exists = $this->exists(['slug' => $value, 'id !=' => $context['data']['id']]);
}
return !$exists;
},
'message' => __('This slug is already in use in articles. Please enter a unique slug.'),
])
->add('slug', 'uniqueInSlugs', [ // adding rules to check slug is unique in slugs table (repeated for other models too)
'rule' => function ($value, $context) {
$slugsTable = TableRegistry::getTableLocator()->get('Slugs');
$exists = $slugsTable->exists(['slug' => $value]);
if ($exists && isset($context['data']['id'])) {
$exists = $slugsTable->exists(['slug' => $value, 'article_id !=' => $context['data']['id']]);
}
return !$exists;
},
'message' => __('Slug conflicts with an existing SEO redirect. Please choose a different slug.'),
]);
There was also slug related code spread elsewhere in the ArticlesTable
class here and more in the beforeSave
and the afterSave
callbacks.
More slug code in the ArticlesTable
beforeSave
callback… ugh… it was getting out of hand.
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): ?bool
{
if ($entity->isNew() && !$entity->slug) {
$sluggedTitle = strtolower(Text::slug($entity->title));
// trim slug to maximum length defined in schema
$entity->slug = substr($sluggedTitle, 0, 255);
//check generated slug is unique
$existing = $this->find('all', conditions: ['slug' => $entity->slug])->first();
if ($existing) {
// If not unique, set the slug back to the entity for user modification
$entity->setError('slug', 'The generated slug is not unique. Please modify it.');
return false; // Prevent save
}
}
On top of that, both had a few lines of code to add the Sluggable Behavior and define the relationship to the slugs
table:
$this->addBehavior('Sluggable', [
'field' => 'title',
'slug' => 'slug',
'maxLength' => 255,
]);
$this->hasMany('Slugs', [
'dependent' => true,
'cascadeCallbacks' => true,
]);
This was making maintaining and testing the code quite hard, so it needed to be refactored into the new SlugBehavior. This behavior handles everything slug related (generation, validation and history tracking) in one place and can be easily attached to any model with custom configuration for the source and destination fields. It has some really nice ways to use it:
- Basic Usage:
// In your Table class (e.g., ArticlesTable.php)
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Slug');
}
- Custom Configuration:
// Customize source field, target field, and maximum length
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Slug', [
'sourceField' => 'name', // Generate slug from 'name' instead of 'title'
'targetField' => 'url_path', // Store slug in 'url_path' instead of 'slug'
'maxLength' => 100, // Limit slug length to 100 characters
]);
}
- Automatic Slug Generation:
// The behavior automatically generates slugs
$article = $articlesTable->newEntity([
'title' => 'My First Blog Post!',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "my-first-blog-post"
// Works with special characters
$article = $articlesTable->newEntity([
'title' => 'Café & Résumé 2024',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "cafe-resume-2024"
- Manual Slug Override:
// You can provide your own slug
$article = $articlesTable->newEntity([
'title' => 'My Article',
'slug' => 'custom-url-path',
]);
$articlesTable->save($article);
echo $article->slug; // Outputs: "custom-url-path"
- Slug History Tracking:
// Original article
$article = $articlesTable->newEntity([
'title' => 'Original Title',
]);
$articlesTable->save($article);
// slug: "original-title"
// Update the title
$article->title = 'New Updated Title';
$article->slug = ''; // Clear the old slug
$articlesTable->save($article);
// slug: "new-updated-title"
// Both slugs are now in the slugs table
// You can find the article using either slug in
// the front end of Willow CMS with old slugs
// giving a 301 redirect to the latest slug
- Accessing Slug History:
// Get all historical slugs for an article
$article = $articlesTable->get($id, [
'contain' => ['Slugs'],
]);
foreach ($article->slugs as $slug) {
echo $slug->slug . ' (created: ' . $slug->created->format('Y-m-d') . ')\n';
}
- Finding Content by Slug:
// Find an article by its current or historical slug
public function findBySlug($slug)
{
return $this->find()
->where(['slug' => $slug])
->orMatchingInBatches('Slugs', function ($q) use ($slug) {
return $q->where(['Slugs.slug' => $slug]);
})
->first();
}
This refactoring of code into a behavior has made it much easier to test the slug functionality. Take a look at the specific test case written for the behavior here.
Wrap Up
This round of refactoring (which mostly moving code into a couple of behaviors and making it work generically for any model) has:
- Reduced the size of the
ArticlesTable
class by 34%, from 740 to 488 lines of code (including comments). - Reduced the size of the
TagsTable
class by 31%, from 430 to 295 lines of code (including comments). - Added better slugs functionality for tags within Willow CMS.
- Made the logic for both slugs and modifying the tree hierarchy more maintainable and testable.
You know understand how:
- CakePHP Behaviors enhance code reusability: Behaviors encapsulate reusable logic, reducing redundancy and promoting DRY principles.
- Centralising logic improves maintainability: By consolidating scattered code into behaviors, we created a single point of maintenance for tree ordering and slug management.
- Behaviors facilitate easier testing: The modular nature of behaviors makes testing individual components of your application’s logic more straightforward.
- Consistent use of naming conventions and callbacks improves code readability: Following CakePHP’s established conventions ensures a consistent and understandable codebase.