





















































In this article by Charles R. Portwood II, the author of Yii Project Blueprints, we will look at how to create a feature-complete content management system and blogging platform.
(For more resources related to this topic, see here.)
Our CMS can be broken down into several different components:
The first component of our application is the users who will perform all the tasks in our application. For this application, we're going to largely reuse the user database and authentication system. In this article, we'll enhance this functionality by allowing social authentication. Our CMS will allow users to register new accounts from the data provided by Twitter; after they have registered, the CMS will allow them to sign-in to our application by signing in to Twitter.
To enable us to know if a user is a socially authenticated user, we have to make several changes to both our database and our authentication scheme. First, we're going to need a way to indicate whether a user is a socially authenticated user. Rather than hardcoding a isAuthenticatedViaTwitter column in our database, we'll create a new database table called user_metadata, which will be a simple table that contains the user's ID, a unique key, and a value. This will allow us to store additional information about our users without having to explicitly change our user's database table every time we want to make a change:
ID INTEGER PRIMARY KEY
user_id INTEGER
key STRING
value STRING
created INTEGER
updated INTEGER
We'll also need to modify our UserIdentity class to allow socially authenticated users to sign in. To do this, we'll be expanding upon this class to create a RemoteUserIdentity class that will work off the OAuth codes that Twitter (or any other third-party source that works with HybridAuth) provide to us rather than authenticating against a username and password.
At the core of our CMS is our content that we'll manage. For this project, we'll manage simple blog posts that can have additional metadata associated with them. Each post will have a title, a body, an author, a category, a unique URI or slug, and an indication whether it has been published or not. Our database structure for this table will look as follows:
ID INTEGER PRIMARY KEY
title STRING
body TEXT
published INTEGER
author_id INTEGER
category_id INTEGER
slug STRING
created INTEGER
updated INTEGER
Each post will also have one or many metadata columns that will further describe the posts we'll be creating. We can use this table (we’ll call it content_metadata) to have our system store information about each post automatically for us, or add information to our posts ourselves, thereby eliminating the need to constantly migrate our database every time we want to add a new attribute to our content:
ID INTEGER PRIMARY KEY
content_id INTEGER
key STRING
value STRING
created INTEGER
updated INTEGER
Each post will be associated with a category in our system. These categories will help us further refine our posts. As with our content, each category will have its own slug. Before either a post or a category is saved, we'll need to verify that the slug is not already in use. Our table structure will look as follows:
ID INTEGER PRIMARY KEY
name STRING
description TEXT
slug STRING
created INTEGER
updated INTEGER
The last core component of our application is optimization for search engines so that our content can be indexed quickly. SEO is important because it increases our discoverability and availability both on search engines and on other marketing materials. In our application, there are a couple of things we'll perform to improve our SEO:
To provide us with a common starting ground, a skeleton project has been included with the project resources for this article. Included with this skeleton project are the necessary migrations, data files, controllers, and views to get us started with developing. Also included in this skeleton project are the user authentication classes. Copy this skeleton project to your web server, configure it so that it responds to chapter6.example.com as outlined at the beginning of the article, and then perform the following steps to make sure everything is set up:
Composer install
php protected/yiic.php migrate up --interactive=0
psql ch6_cms -f protected/data/postgres.sql
'username' => '<username>',
'password' => '<password>',
'from' => '[email protected]'
)
If everything is loaded correctly, you should see a 404 page similar to the following:
There are actually a lot of different things going on in the background to make this work even if this is just a 404 error. Before we start doing any development, let's take a look at a few of the classes that have been provided in our skeleton project in the protected/components folder.
The first class that has been provided to us is an ActiveRecord extension called CMSActiveRecord that all of our models will stem from. This class allows us to reduce the amount of code that we have to write in each class. For now, we'll simply add CTimestampBehavior and the afterFind() method to store the old attributes for the time the need arises to compare the changed attributes with the new attributes:
class CMSActiveRecordCMSActiveRecord extends CActiveRecord
{
public $_oldAttributes = array();
public function behaviors()
{
return array(
'CTimestampBehavior' => array(
'class' => 'zii.behaviors.CTimestampBehavior',
'createAttribute' => 'created',
'updateAttribute' => 'updated',
'setUpdateOnCreate' => true
)
);
}
public function afterFind()
{
if ($this !== NULL)
$this->_oldAttributes = $this->attributes;
return parent::afterFind();
}
}
Since both Content and Category classes have slugs, we'll need to add a custom validator to each class that will enable us to ensure that the slug is not already in use by either a post or a category. To do this, we have another class called CMSSlugActiveRecord that extends CMSActiveRecord with a validateSlug() method that we'll implement as follows:
class CMSSLugActiveRecord extends CMSActiveRecord
{
public function validateSlug($attributes, $params)
{
// Fetch any records that have that slug
$content = Content::model()->findByAttributes(array('slug' =>
$this->slug));
$category = Category::model()->findByAttributes(array('slug' =>
$this->slug));
$class = strtolower(get_class($this));
if ($content == NULL && $category == NULL)
return true;
else if (($content == NULL && $category != NULL) || ($content !=
NULL && $category == NULL))
{
$this->addError('slug', 'That slug is already in use');
return false;
}
else
{
if ($this->id == $$class->id)
return true;
}
$this->addError('slug', 'That slug is already in use');
return false;
}
}
This implementation simply checks the database for any item that has that slug. If nothing is found, or if the current item is the item that is being modified, then the validator will return true. Otherwise, it will add an error to the slug attribute and return false. Both our Content model and Category model will extend from this class.
One of the largest challenges of working with larger applications is changing their appearance without locking functionality into our views. One way to further separate our business logic from our presentation logic is to use themes. Using themes in Yii, we can dynamically change the presentation layer of our application simply utilizing the Yii::app()->setTheme('themename) method. Once this method is called, Yii will look for view files in themes/themename/views rather than protected/views. Throughout the rest of the article, we'll be adding views to a custom theme called main, which is located in the themes folder. To set this theme globally, we'll be creating a custom class called CMSController, which all of our controllers will extend from. For now, our theme name will be hardcoded within our application. This value could easily be retrieved from a database though, allowing us to dynamically change themes from a cached or database value rather than changing it in our controller. Have a look at the following lines of code:
class CMSController extends CController
{
public function beforeAction($action)
{
Yii::app()->setTheme('main');
return parent::beforeAction($action);
}
}
In our previous applications, we had long, boring URL's that had lots of IDs and parameters in them. These URLs provided a terrible user experience and prevented search engines and users from knowing what the content was about at a glance, which in turn would hurt our SEO rankings on many search engines. To get around this, we're going to heavily modify our UrlManager class to allow truly dynamic routing, which means that, every time we create or update a post or a category, our URL rules will be updated.
Before we can start working on our controllers, we need to create a custom UrlManager to handle routing of our content so that we can access our content by its slug. The steps are as follows:
'urlManager' => array(
'class' => 'application.components.
CMSUrlManager',
'urlFormat' => 'path',
'showScriptName' => false
)
class CMSUrlManager extends CUrlManager {}
public $defaultRules = array(
'/sitemap.xml' => '/content/sitemap',
'/search/<page:d+>' => '/content/search',
'/search' => '/content/search',
'/blog/<page:d+>' => '/content/index',
'/blog' => '/content/index',
'/' => '/content/index',
'/hybrid/<provider:w+>' => '/hybrid/index',
);
protected function processRules() {}
$this->rules = !YII_DEBUG ? Yii::app()->cache->get('Routes') : array();
If the rules we get back are already set up, we'll simple return them; otherwise, we'll generate the rules, put them into our cache, and then append our basic URL rules:
if ($this->rules == false || empty($this->rules)) { $this->rules = array(); $this->rules = $this->generateClientRules(); $this->rules = CMap::mergearray($this->addRssRules(), $this- >rules); Yii::app()->cache->set('Routes', $this->rules); } $this->rules['<controller:w+>/<action:w+>/<id:w+>'] = '/'; $this->rules['<controller:w+>/<action:w+>'] = '/'; return parent::processRules();
The first method, generateClientRules(), simply loads our default rules that we defined earlier with the rules generated from our content and categories, which are populated by the generateRules() method:
private function generateClientRules() { $rules = CMap::mergeArray($this->defaultRules, $this->rules); return CMap::mergeArray($this->generateRules(), $rules); } private function generateRules() { return CMap::mergeArray($this->generateContentRules(), $this- >generateCategoryRules()); }
array( '<slug>' => '<controller>/<action>/id/<id>' )
Content rules will consist of an entry that is published. Have a look at the following code:
private function generateContentRules()
{
$rules = array();
$criteria = new CDbCriteria;
$criteria->addCondition('published = 1');
$content = Content::model()->findAll($criteria);
foreach ($content as $el)
{
if ($el->slug == NULL)
continue;
$pageRule = $el->slug.'/<page:d+>';
$rule = $el->slug;
if ($el->slug == '/')
$pageRule = $rule = '';
$pageRule = $el->slug . '/<page:d+>';
$rule = $el->slug;
$rules[$pageRule] = "content/view/id/{$el->id}";
$rules[$rule] = "content/view/id/{$el->id}";
}
return $rules;
}
private function generateCategoryRules() { $rules = array(); $categories = Category::model()->findAll(); foreach ($categories as $el) { if ($el->slug == NULL) continue; $pageRule = $el->slug.'/<page:d+>'; $rule = $el->slug; if ($el->slug == '/') $pageRule = $rule = ''; $pageRule = $el->slug . '/<page:d+>'; $rule = $el->slug; $rules[$pageRule] = "category/index/id/{$el->id}"; $rules[$rule] = "category/index/id/{$el->id}"; } return $rules; }
private function addRSSRules() { $categories = Category::model()->findAll(); foreach ($categories as $category) $routes[$category->slug.'.rss'] = "category/rss/id/ {$category->id}"; $routes['blog.rss'] = '/category/rss'; return $routes; }
Now that Yii knows how to route our content, we can begin work on displaying and managing it. Begin by creating a new controller called ContentController in protected/controllers that extends CMSController. Have a look at the following line of code:
class ContentController extends CMSController {}
To start with, we'll define our accessRules() method and the default layout that we're going to use. Here's how:
public $layout = 'default';
public function filters()
{
return array(
'accessControl',
);
}
public function accessRules()
{
return array(
array('allow',
'actions' => array('index', 'view', 'search'),
'users' => array('*')
),
array('allow',
'actions' => array('admin', 'save', 'delete'),
'users'=>array('@'),
'expression' => 'Yii::app()->user->role==2'
),
array('deny', // deny all users
'users'=>array('*'),
),
);
}
The first method we'll be implementing is our sitemap action. In our ContentController, create a new method called actionSitemap():
public function actionSitemap() {}
The steps to be performed are as follows:
Yii::app()->log->routes[0]->enabled = false;
ob_end_clean();
header('Content-type: text/xml; charset=utf-8');
$this->layout = false;
$content = Content::model()->findAllByAttributes(array('published'
=> 1));
$categories = Category::model()->findAll();
$this->renderPartial('sitemap', array(
'content' => $content,
'categories' => $categories,
'url' => 'http://'.Yii::app()->request->serverName .
Yii::app()->baseUrl
))
<?php echo '<?xml version="1.0" encoding="UTF-8"?>'; ?>
<urlset >
<?php foreach ($content as $v): ?>
<url>
<loc><?php echo $url .'/'. htmlspecialchars(str_
replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></loc>
<lastmod><?php echo date('c',
strtotime($v['updated']));?></lastmod>
<changefreq>weekly</changefreq>
<priority>1</priority>
</url>
<?php endforeach; ?>
<?php foreach ($categories as $v): ?>
<url>
<loc><?php echo $url .'/'. htmlspecialchars(str_
replace('/', '', $v['slug']), ENT_QUOTES, "utf-8"); ?></loc>
<lastmod><?php echo date('c',
strtotime($v['updated']));?></lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<?php endforeach; ?>
</urlset>
You can now load http://chapter6.example.com/sitemap.xml in your browser to see the sitemap. Before you make your site live, be sure to submit this file to search engines for them to index.
Next, we'll implement the actions necessary to display all of our content and a particular post. We'll start by providing a paginated view of our posts. Since CListView and the Content model's search() method already provide this functionality, we can utilize those classes to generate and display this data:
return new CActiveDataProvider($this, array(
'criteria' =>$criteria,
'pagination' => array(
'pageSize' => 5,
'pageVar' =>'page'
)
));
public function actionIndex($page=1)
{
// Model Search without $_GET params
$model = new Content('search');
$model->unsetAttributes();
$model->published = 1;
$this->render('//content/all', array(
'dataprovider' => $model->search()
));
}
<?php $this->widget('zii.widgets.CListView', array(
'dataProvider'=>$dataprovider,
'itemView'=>'//content/list',
'summaryText' => '',
'pager' => array(
'htmlOptions' => array(
'class' => 'pager'
),
'header' => '',
'firstPageCssClass'=>'hide',
'lastPageCssClass'=>'hide',
'maxButtonCount' => 0
)
));
Since our database has already been populated with some sample data, you can start playing around with the results right away, as shown in the following screenshot:
Since our routing rules are already set up, displaying our content is extremely simple. All that we have to do is search for a published model with the ID passed to the view action and render it:
public function actionView($id=NULL)
{
// Retrieve the data
$content = Content::model()->findByPk($id);
// beforeViewAction should catch this
if ($content == NULL || !$content->published)
throw new CHttpException(404, 'The article you specified does
not exist.');
$this->render('view', array(
'id' => $id,
'post' => $content
));
}
After copying themes/main/views/content/view.php from the project resources folder into your project, you'll be able to click into a particular post from the home page. In its actions present form, this action has introduced an interesting side effect that could negatively impact our SEO rankings on search engines—the same entry can now be accessed from two URI's. For example, http://chapter6.example.com/content/view/id/1 and http://chapter6.example.com/quis-condimentum-tortor now bring up the same post. Fortunately, correcting this bug is fairly easy. Since the goal of our slugs is to provide more descriptive URI's, we'll simply block access to the view if a user tries to access it from the non-slugged URI.
We'll do this by creating a new method called beforeViewAction() that takes the entry ID as a parameter and gets called right after the actionView() method is called. This private method will simply check the URI from CHttpRequest to determine how actionView was accessed and return a 404 if it's not through our beautiful slugs:
private function beforeViewAction($id=NULL)
{
// If we do not have an ID, consider it to be null, and throw a 404
error
if ($id == NULL)
throw new CHttpException(404,'The specified post cannot be
found.');
// Retrieve the HTTP Request
$r = new CHttpRequest();
// Retrieve what the actual URI
$requestUri = str_replace($r->baseUrl, '', $r->requestUri);
// Retrieve the route
$route = '/' . $this->getRoute() . '/' . $id;
$requestUri = preg_replace('/?(.*)/','',$requestUri);
// If the route and the uri are the same, then a direct access
attempt was made, and we need to block access to the controller
if ($requestUri == $route)
throw new CHttpException(404, 'The requested post cannot be
found.');
return str_replace($r->baseUrl, '', $r->requestUri);
}
Then right after our actionView starts, we can simultaneously set the correct return URL and block access to the content if it wasn't accessed through the slug as follows:
Yii::app()->user->setReturnUrl($this->beforeViewAction($id));
Presently, our content is only informative in nature—we have no way for our users to communicate with us what they thought about our entry. To encourage engagement, we can add a commenting system to our CMS to further engage with our readers. Rather than writing our own commenting system, we can leverage comment through Disqus, a free, third-party commenting system. Even through Disqus, comments are implemented in JavaScript and we can create a custom widget wrapper for it to display comments on our site. The steps are as follows:
'disqus' => array(
'shortname' => 'ch6disqusexample',
)
class DisqusWidget extends CWidget {}
public $shortname = NULL;
public $identifier = NULL;
public $url = NULL;
public $title = NULL;
public function init()
{
parent::init();
if ($this->shortname == NULL)
throw new CHttpException(500, 'Disqus shortname is
required');
echo "<div id='disqus_thread'></div>";
Yii::app()->clientScript->registerScript('disqus', "
var disqus_shortname = '{$this->shortname}';
var disqus_identifier = '{$this->identifier}';
var disqus_url = '{$this->url}';
var disqus_title = '{$this->title}';
/* * * DON'T EDIT BELOW THIS LINE * * */
(function() {
var dsq = document.createElement('script'); dsq.type =
'text/javascript'; dsq.async = true;
dsq.src = '//' + disqus_shortname + '.disqus.com/
embed.js';
(document.getElementsByTagName('head')[0] || document.
getElementsByTagName('body')[0]).appendChild(dsq);
})();
");
}
<?php $this->widget('DisqusWidget', array(
'shortname' => Yii::app()->params['includes']['disqus']
['shortname'],
'url' => $this->createAbsoluteUrl('/'.$post->slug),
'title' => $post->title,
'identifier' => $post->id
)); ?>
Now, when you load any given post, Disqus comments will also be loaded with that post. Go ahead and give it a try!
Next, we'll implement a search method so that our users can search for posts. To do this, we'll implement an instance of CActiveDataProvider and pass that data to our themes/main/views/content/all.php view to be rendered and paginated:
public function actionSearch()
{
$param = Yii::app()->request->getParam('q');
$criteria = new CDbCriteria;
$criteria->addSearchCondition('title',$param,'OR');
$criteria->addSearchCondition('body',$param,'OR');
$dataprovider = new CActiveDataProvider('Content', array(
'criteria'=>$criteria,
'pagination' => array(
'pageSize' => 5,
'pageVar'=>'page'
)
));
$this->render('//content/all', array(
'dataprovider' => $dataprovider
));
}
Since our view file already exists, we can now search for content in our CMS.
Next, we'll implement a basic set of management tools that will allow us to create, update, and delete entries:
private function loadModel($id=NULL)
{
if ($id == NULL)
throw new CHttpException(404, 'No category with that ID
exists');
$model = Content::model()->findByPk($id);
if ($model == NULL)
throw new CHttpException(404, 'No category with that ID
exists');
return $model;
}
public function actionDelete($id)
{
$this->loadModel($id)->delete();
$this->redirect($this->createUrl('content/admin'));
}
public function actionAdmin()
{
$model = new Content('search');
$model->unsetAttributes();
if (isset($_GET['Content']))
$model->attributes = $_GET;
$this->render('admin', array(
'model' => $model
));
}
public function actionSave($id=NULL)
{
if ($id == NULL)
$model = new Content;
else
$model = $this->loadModel($id);
if (isset($_POST['Content']))
{
$model->attributes = $_POST['Content'];
$model->author_id = Yii::app()->user->id;
if ($model->save())
{
Yii::app()->user->setFlash('info', 'The articles was
saved');
$this->redirect($this->createUrl('content/admin'));
}
}
$this->render('save', array(
'model' => $model
));
}
At this point, you can now log in to the system using the credentials provided in the following table and start managing entries:
Username |
Password |
test |
|
test |
In this article, we dug deeper into Yii framework by manipulating our CUrlManager class to generate completely dynamic and clean URIs. We also covered the use of Yii's built-in theming to dynamically change the frontend appearance of our site by simply changing a configuration value.
Further resources on this subject: