With the basic article creation functionality built, we need to enable multiple
authors to work in our CMS. Previously, we built all the models, views and
controllers by hand. This time around we’re going to use
Bake Console to create our skeleton code. Bake is a powerful
code generation CLI tool that leverages the
conventions CakePHP uses to create skeleton CRUD applications very efficiently. We’re going to use bake
to build our
users code:
cd /path/to/our/app
# You can overwrite any existing files.
bin/cake bake model users
bin/cake bake controller users
bin/cake bake template users
These 3 commands will generate:
The Table, Entity, Fixture files.
The Controller
The CRUD templates.
Test cases for each generated class.
Bake will also use the CakePHP conventions to infer the associations, and validation your models have.
With multiple users able to access our small CMS it would be nice to
have a way to categorize our content. We’ll use tags and tagging to allow users
to create free-form categories and labels for their content. Again, we’ll use
bake
to quickly generate some skeleton code for our application:
# Generate all the code at once.
bin/cake bake all tags
Once you have the scaffold code created, create a few sample tags by going to http://localhost:8765/tags/add.
Now that we have a Tags table, we can create an association between Articles and
Tags. We can do so by adding the following to the initialize
method on the
ArticlesTable
:
public function initialize(array $config): void
{
$this->addBehavior('Timestamp');
$this->belongsToMany('Tags'); // Add this line
}
This association will work with this simple definition because we followed CakePHP conventions when creating our tables. For more information, read Associations - Linking Tables Together.
Now that our application has tags, we need to enable users to tag their
articles. First, update the add
action to look like:
<?php
// in src/Controller/ArticlesController.php
namespace App\Controller;
use App\Controller\AppController;
class ArticlesController extends AppController
{
public function add()
{
$article = $this->Articles->newEmptyEntity();
if ($this->request->is('post')) {
$article = $this->Articles->patchEntity($article, $this->request->getData());
// Hardcoding the user_id is temporary, and will be removed later
// when we build authentication out.
$article->user_id = 1;
if ($this->Articles->save($article)) {
$this->Flash->success(__('Your article has been saved.'));
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('Unable to add your article.'));
}
// Get a list of tags.
$tags = $this->Articles->Tags->find('list')->all();
// Set tags to the view context
$this->set('tags', $tags);
$this->set('article', $article);
}
// Other actions
}
The added lines load a list of tags as an associative array of id => title
.
This format will let us create a new tag input in our template.
Add the following to the PHP block of controls in templates/Articles/add.php:
echo $this->Form->control('tags._ids', ['options' => $tags]);
This will render a multiple select element that uses the $tags
variable to
generate the select box options. You should now create a couple new articles
that have tags, as in the following section we’ll be adding the ability to find
articles by tags.
You should also update the edit
method to allow adding or editing tags. The
edit method should now look like:
public function edit($slug)
{
$article = $this->Articles
->findBySlug($slug)
->contain('Tags') // load associated Tags
->firstOrFail();
if ($this->request->is(['post', 'put'])) {
$this->Articles->patchEntity($article, $this->request->getData());
if ($this->Articles->save($article)) {
$this->Flash->success(__('Your article has been updated.'));
return $this->redirect(['action' => 'index']);
}
$this->Flash->error(__('Unable to update your article.'));
}
// Get a list of tags.
$tags = $this->Articles->Tags->find('list')->all();
// Set tags to the view context
$this->set('tags', $tags);
$this->set('article', $article);
}
Remember to add the new tags multiple select control we added to the add.php template to the templates/Articles/edit.php template as well.
Once users have categorized their content, they will want to find that content by the tags they used. For this feature we’ll implement a route, controller action, and finder method to search through articles by tag.
Ideally, we’d have a URL that looks like http://localhost:8765/articles/tagged/funny/cat/gifs. This would let us find all the articles that have the ‘funny’, ‘cat’ or ‘gifs’ tags. Before we can implement this, we’ll add a new route. Your config/routes.php (with the baked comments removed) should look like:
<?php
use Cake\Routing\Route\DashedRoute;
use Cake\Routing\RouteBuilder;
$routes->setRouteClass(DashedRoute::class);
$routes->scope('/', function (RouteBuilder $builder) {
$builder->connect('/', ['controller' => 'Pages', 'action' => 'display', 'home']);
$builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);
// Add this
// New route we're adding for our tagged action.
// The trailing `*` tells CakePHP that this action has
// passed parameters.
$builder->scope('/articles', function (RouteBuilder $builder) {
$builder->connect('/tagged/*', ['controller' => 'Articles', 'action' => 'tags']);
});
$builder->fallbacks();
});
The above defines a new ‘route’ which connects the /articles/tagged/ path,
to ArticlesController::tags()
. By defining routes, you can isolate how your
URLs look, from how they are implemented. If we were to visit
http://localhost:8765/articles/tagged, we would see a helpful error page
from CakePHP informing you that the controller action does not exist. Let’s
implement that missing method now. In src/Controller/ArticlesController.php
add the following:
public function tags()
{
// The 'pass' key is provided by CakePHP and contains all
// the passed URL path segments in the request.
$tags = $this->request->getParam('pass');
// Use the ArticlesTable to find tagged articles.
$articles = $this->Articles->find('tagged', tags: $tags)
->all();
// Pass variables into the view template context.
$this->set([
'articles' => $articles,
'tags' => $tags
]);
}
To access other parts of the request data, consult the Request section.
Since passed arguments are passed as method parameters, you could also write the action using PHP’s variadic argument:
public function tags(...$tags)
{
// Use the ArticlesTable to find tagged articles.
$articles = $this->Articles->find('tagged', tags: $tags)
->all();
// Pass variables into the view template context.
$this->set([
'articles' => $articles,
'tags' => $tags
]);
}
In CakePHP we like to keep our controller actions slim, and put most of our
application’s logic in the model layer. If you were to visit the
/articles/tagged URL now you would see an error that the findTagged()
method has not been implemented yet, so let’s do that. In
src/Model/Table/ArticlesTable.php add the following:
// add this use statement right below the namespace declaration to import
// the Query class
use Cake\ORM\Query\SelectQuery;
// The $query argument is a query builder instance.
// The $options array will contain the 'tags' option we passed
// to find('tagged') in our controller action.
public function findTagged(SelectQuery $query, array $tags = []): SelectQuery
{
$columns = [
'Articles.id', 'Articles.user_id', 'Articles.title',
'Articles.body', 'Articles.published', 'Articles.created',
'Articles.slug',
];
$query = $query
->select($columns)
->distinct($columns);
if (empty($tags)) {
// If there are no tags provided, find articles that have no tags.
$query->leftJoinWith('Tags')
->where(['Tags.title IS' => null]);
} else {
// Find articles that have one or more of the provided tags.
$query->innerJoinWith('Tags')
->where(['Tags.title IN' => $tags]);
}
return $query->groupBy(['Articles.id']);
}
We just implemented a custom finder method. This is
a very powerful concept in CakePHP that allows you to package up re-usable
queries. Finder methods always get a Query Builder object and an
array of options as parameters. Finders can manipulate the query and add any
required conditions or criteria. When complete, finder methods must return
a modified query object. In our finder we’ve leveraged the distinct()
and
leftJoin()
methods which allow us to find distinct articles that have
a ‘matching’ tag.
Now if you visit the /articles/tagged URL again, CakePHP will show a new error
letting you know that you have not made a view file. Next, let’s build the
view file for our tags()
action:
<!-- In templates/Articles/tags.php -->
<h1>
Articles tagged with
<?= $this->Text->toList(h($tags), 'or') ?>
</h1>
<section>
<?php foreach ($articles as $article): ?>
<article>
<!-- Use the HtmlHelper to create a link -->
<h4><?= $this->Html->link(
$article->title,
['controller' => 'Articles', 'action' => 'view', $article->slug]
) ?></h4>
<span><?= h($article->created) ?></span>
</article>
<?php endforeach; ?>
</section>
In the above code we use the Html and
Text helpers to assist in generating our view output. We
also use the h
shortcut function to HTML encode output. You should
remember to always use h()
when outputting data to prevent HTML injection
issues.
The tags.php file we just created follows the CakePHP conventions for view template files. The convention is to have the template use the lower case and underscored version of the controller action name.
You may notice that we were able to use the $tags
and $articles
variables in our view template. When we use the set()
method in our
controller, we set specific variables to be sent to the view. The View will make
all passed variables available in the template scope as local variables.
You should now be able to visit the /articles/tagged/funny URL and see all the articles tagged with ‘funny’.
Right now, adding new tags is a cumbersome process, as authors need to pre-create all the tags they want to use. We can improve the tag selection UI by using a comma separated text field. This will let us give a better experience to our users, and use some more great features in the ORM.
Because we’ll want a simple way to access the formatted tags for an entity, we can add a virtual/computed field to the entity. In src/Model/Entity/Article.php add the following:
// add this use statement right below the namespace declaration to import
// the Collection class
use Cake\Collection\Collection;
// Update the accessible property to contain `tag_string`
protected array $_accessible = [
//other fields...
'tag_string' => true
];
protected function _getTagString()
{
if (isset($this->_fields['tag_string'])) {
return $this->_fields['tag_string'];
}
if (empty($this->tags)) {
return '';
}
$tags = new Collection($this->tags);
$str = $tags->reduce(function ($string, $tag) {
return $string . $tag->title . ', ';
}, '');
return trim($str, ', ');
}
This will let us access the $article->tag_string
computed property. We’ll
use this property in controls later on.
With the entity updated we can add a new control for our tags. In
templates/Articles/add.php and templates/Articles/edit.php,
replace the existing tags._ids
control with the following:
echo $this->Form->control('tag_string', ['type' => 'text']);
We’ll also need to update the article view template. In templates/Articles/view.php add the line as shown:
<!-- File: templates/Articles/view.php -->
<h1><?= h($article->title) ?></h1>
<p><?= h($article->body) ?></p>
// Add the following line
<p><b>Tags:</b> <?= h($article->tag_string) ?></p>
You should also update the view method to allow retrieving existing tags:
// src/Controller/ArticlesController.php file
public function view($slug = null)
{
// Update retrieving tags with contain()
$article = $this->Articles
->findBySlug($slug)
->contain('Tags')
->firstOrFail();
$this->set(compact('article'));
}
Now that we can view existing tags as a string, we’ll want to save that data as
well. Because we marked the tag_string
as accessible, the ORM will copy that
data from the request into our entity. We can use a beforeSave()
hook method
to parse the tag string and find/build the related entities. Add the following
to src/Model/Table/ArticlesTable.php:
public function beforeSave(EventInterface $event, $entity, $options)
{
if ($entity->tag_string) {
$entity->tags = $this->_buildTags($entity->tag_string);
}
// Other code
}
protected function _buildTags($tagString)
{
// Trim tags
$newTags = array_map('trim', explode(',', $tagString));
// Remove all empty tags
$newTags = array_filter($newTags);
// Reduce duplicated tags
$newTags = array_unique($newTags);
$out = [];
$tags = $this->Tags->find()
->where(['Tags.title IN' => $newTags])
->all();
// Remove existing tags from the list of new tags.
foreach ($tags->extract('title') as $existing) {
$index = array_search($existing, $newTags);
if ($index !== false) {
unset($newTags[$index]);
}
}
// Add existing tags.
foreach ($tags as $tag) {
$out[] = $tag;
}
// Add new tags.
foreach ($newTags as $tag) {
$out[] = $this->Tags->newEntity(['title' => $tag]);
}
return $out;
}
If you now create or edit articles, you should be able to save tags as a comma separated list of tags, and have the tags and linking records automatically created.
While this code is a bit more complicated than what we’ve done so far, it helps to showcase how powerful the ORM in CakePHP is. You can manipulate query results using the Collections methods, and handle scenarios where you are creating entities on the fly with ease.
Before we finish up, we’ll need a mechanism that will load the associated tags (if any) whenever we load an article.
In your src/Model/Table/ArticlesTable.php, change:
public function initialize(array $config): void
{
$this->addBehavior('Timestamp');
// Change this line
$this->belongsToMany('Tags', [
'joinTable' => 'articles_tags',
'dependent' => true
]);
}
This will tell the Articles table model that there is a join table associated with tags. The ‘dependent’ option tells the table to delete any associated records from the join table if an article is deleted.
Lastly, update the findBySlug() method calls in src/Controller/ArticlesController.php:
public function edit($slug)
{
// Update this line
$article = $this->Articles
->findBySlug($slug)
->contain('Tags')
->firstOrFail();
...
}
public function view($slug = null)
{
// Update this line
$article = $this->Articles
->findBySlug($slug)
->contain('Tags')
->firstOrFail();
$this->set(compact('article'));
}
The contain()
method tells the ArticlesTable
object to also populate the
Tags association when the article is loaded. Now when tag_string is called for
an Article entity, there will be data present to create the string!
Next we’ll be adding authentication.