Cascading Dynamic Meta Tags and Page Titles in CakePHP 1.2

In the course of developing my general purpose CakePHP CMS, I needed a way to manipulate the meta tags, page title, and page heading for every URL in the website. Now, this task is pretty simple when dealing with the pages controller - just add the appropriate meta columns to the pages table and you’re off to the races. But what about URLs that handle other controllers, especially ones that don’t have CMS-editable page content?

Well, I hmmm’ed over the idea for a while before settling on my solution: a separate database table, metas. This table and its data would be manipulated like any other MVC component in the CMS, except that each row in the metas table would be associated with a URL on the website. And, since it would be a lot of work to cover absolutely every URL in a website, this Meta feature should cascade, so that if, for example, I don’t have meta information for “/places/canada/victoria”, the code would automatically pull the metas for “/places/canada” (and if that’s empty, “/places/”, or just “/”).

So I wrote it and, hey, it turned out to be pretty simple and useful, so much so that I whipped up a little tutorial to implement the Meta feature. So without further delay, here’s the tutorial, and here’s how it’s going to go down:

  1. Create a metas database table
  2. Create a controller, model, and a couple of views to manipulate our metas
  3. Write a function to get the meta details of the current page
  4. Add meta initialization to AppController
  5. Add the dynamic metas to our layout

Create a metas database table

Before we build any of the exciting stuff we need to create a metas table in our database (I’m using MySQL). Mine looks lke this:


CREATE TABLE `metas` (
 `id` int(11) NOT NULL auto_increment,
 `created` datetime default NULL,
 `modified` datetime default NULL,
 `url` varchar(255) NOT NULL,
 `page_header` varchar(255) NOT NULL,
 `meta_description` text,
 `meta_keywords` text,
 `head_title` varchar(255) NOT NULL,
 PRIMARY KEY  (`id`)
) ENGINE=MyISAM;

Pretty straightforward: ‘url’ is, predictably, the URL of a page we want to create meta information for. ‘page_header’ defines the title of the page within the content, while ‘head_title’ is used for the title tag in the page head.

Create a controller, model, and a couple of views to manipulate our metas

Now that we have our database table, we’ll need to add the standard MVC components. Let’s start with the controller.

The controller is simple - it’s basically a baked version of a standard controller. Nothing fancy at all, except that, instead of having separate admin_edit and admin_add functions, I combine them both in admin_edit (thanks to Matt Curry for the idea). So, /app/controllers/metas_controller.php:

<?php
class MetasController extends AppController {
	var $name = 'Metas';

	function admin_index() {
		$this->set('metas', $this->paginate());
	}

	function admin_edit($id = null) {
		if (!empty($this->data)) {
			if ($this->Meta->save($this->data)) {
				$this->Session->setFlash(__('The meta information has been saved', true));
				$this->redirect(array('action'=>'index'));
			} else {
			}
		}
		if ($id && empty($this->data)) {
			$this->data = $this->Meta->read(null, $id);
		}
	}

	function admin_delete($id = null) {
		if (!$id) {
			$this->Session->setFlash(__('Invalid id for meta', true));
			$this->redirect(array('action'=>'index'));
		}
		if ($this->Meta->del($id)) {
			$this->Session->setFlash(__('Meta information deleted', true));
			$this->redirect(array('action'=>'index'));
		}
	}

}
?>

The views are just as simple as the controller. Again, mostly baked stuff.

/app/views/metas/admin_index.ctp:

<div id="controller_actions">
 <?php echo $html->link(__('New Meta Entry', true), array('action'=>'edit')); ?></div>
<div class="metas index">
<h2><?php __('Meta');?></h2>
<?php
echo $paginator->counter(array(
'format' => __('Page %page% of %pages%, showing %current% records out of %count% total, starting on record %start%, ending on %end%.', true)
));
?>
<table cellpadding="0" cellspacing="0" class="tbl_list">
<tr>
<th><?php echo $paginator->sort('url');?></th>
<th><?php echo $paginator->sort('page_header');?></th>
<th><?php echo $paginator->sort('created');?></th>
<th><?php echo $paginator->sort('modified');?></th>
<th class="actions"><?php __('Actions');?></th>
</tr>
<?php
$i = 0;
foreach ($metas as $meta):
 $class = null;
 if ($i++ % 2 == 0) {
 $class = ' class="altrow"';
 }
?>
<tr<?php echo $class;?>>
<td>
 <?php echo $meta['Meta']['url']; ?></td>
<td>
 <?php echo $meta['Meta']['page_header']; ?></td>
<td>
 <?php echo $time->nice($meta['Meta']['created']); ?></td>
<td>
 <?php echo $time->nice($meta['Meta']['modified']); ?></td>
<td class="actions">
 <?php echo $html->link(__('Edit', true), array('action'=>'edit', $meta['Meta']['id'])); ?>
 <?php echo $html->link(__('Delete', true), array('action'=>'delete', $meta['Meta']['id']), null, sprintf(__('Are you sure you want to delete # %s?', true), $meta['Meta']['id'])); ?></td>
</tr>
<?php endforeach; ?></table>
</div>
<div class="paging">
 <?php echo $paginator->prev('<< '.__('previous', true), array(), null, array('class'=>'disabled'));?>
 | 	<?php echo $paginator->numbers();?>
 <?php echo $paginator->next(__('next', true).' >>', array(), null, array('class'=>'disabled'));?></div>

/app/views/metas/admin_edit.ctp:

<div id="controller_actions">
<ul>
 <?php echo $html->link(__('Delete', true), array('action'=>'delete', $form->value('Meta.id')), null, sprintf(__('Are you sure you want to delete # %s?', true), $form->value('Meta.id'))); ?> |
 <?php echo $html->link(__('List Metas', true), array('action'=>'index'));?></ul>
</div>
<h2>Add/Edit Meta Entry</h2>
<?php echo $form->create('Meta', array('class' => 'editor_form'));?>
 <?php
 echo $form->input('id');
 echo $form->input('url');
 echo $form->input('title', array('label' => 'Page Title'));
 echo $form->input('head_title', array('label' => 'Head Title'));
 echo $form->input('meta_description');
 echo $form->input('meta_keywords');
 ?>
<?php echo $form->end('Submit');?>

And finally, the model. We’ll make a simple one now and then, in the next step, we’ll write our meta-finding method.

/apps/models/meta.php:

<?php
class Meta extends AppModel  {
 var $name = 'Meta';
}

Write a function to get the meta details of the current page

OK, so now that we’ve finished our MVC implementation of the Meta model, let’s write the meat of our new feature: a function that finds the meta information for the current URL. If no such information is found, we’ll ‘cascade’ down through the URL until we find a match, eventually settling on the root entry (”/”) if we can’t find any other metas.

So, put this function in /app/models/meta.php:

/*
 * Get the meta model for the current page ($this->here from AppController).
 */
function __findCurrentPage($options = array()) {

	if (!isset($options['url'])) {
		return NULL;
	}

	$url = rtrim($options['url'], '/');

	/*
	 * First we try to find a complete match for the URL. If we can find it, or if
	 * we're at the root of the site, return the results.
	 */
	$meta = $this->find('first', array('conditions' => array('url' => $url)));
	if (!empty($meta) || $url == '/') {
		return $meta;
	}

	/*
	 * We didn't find a match (or we're not in the root), so now we explode the URL
	 * into its parts (separated by /), and look for a match. In other words, we cascade
	 * down the URL until the root in order to find a meta entry.
	 */
	$urlParts = explode("/", trim($url, "/"));
	krsort($urlParts);

	foreach ((array)$urlParts as $part) {
		$url = str_replace('/'.$part, '', $url);
		if ($url) {
			$meta = $this->find('first', array('conditions' => array('url' => $url)));
			if (!empty($meta)) {
				return $meta;
			}
		}
	}

	/*
	 * Still no matching meta, so now we just return the metas for the root.
	 */
	$meta = $this->find('first', array('conditions' => array('url' => '/')));
	return $meta;
}

So, if our URL is “/canucks/the-team/ryan-kesler”, the function will first look for a complete URL match, then move down to “/canucks/the-team”, then just “/canucks”, and then simply “/”. A pretty simple and intuitive way to handle CMS-manipulated meta information.

Add meta initialization to AppController

Now that we’ve finished the bulk of our work, we just need to grab the meta info so it’s available to our page views. We’ll accomplish this by adding a handful of simple functions to AppController (/app/app_controller.php):

/**
 * Search the metas table for an entry matching the current URL. If no match is
 * found, keep cascading through the URL path separator ('/') until we find an entry.
 *
 */
private function _configureMeta()
{
	$meta = ClassRegistry::init('Meta')->find('currentPage', array('url' => $this->here));
	$meta = $meta['Meta'];
	$this->meta = $meta;

	return $this->meta;
}

// Page title (<title>)
private function _pageTitle()
{
   return ($this->meta['head_title'] ? $this->meta['page_header'] : NULL);
}

// Page header (<h1)
private function _pageHeader()
{
   return ($this->meta['page_header'] ? $this->meta['page_header'] : NULL);
}       

// Meta keywords
private function _metaKeywords()
{
	   return (!empty($this->meta['meta_keywords']) ? $this->meta['meta_keywords'] : NULL);
}

// Meta description
private function _metaDescription()
{
   return (!empty($this->meta['meta_description']) ? $this->meta['meta_description'] : NULL);
}

The methods are nothing fancy: _configureMeta() loads the meta info for the current page, calling up the findCurrentPage function we wrote in the Meta class. The other four methods simply grab the information. We’ll call all of these methods - thereby setting the view variables - in AppController::beforeRender():

function beforeRender() {
...
    // Grab our dynamic page <title>.
    $this->pageTitle = $this->_pageTitle();

    // Set the page header.
    $this->set('pageHeader', $this->_pageHeader());

    // Grab our dynamic meta keywords and description.
    $this->set('metaKeywords', $this->_metaKeywords());
    $this->set('metaDescription', $this->_metaDescription());
...
}

OK! We’ve set $title_for_layout with _pageTitle(), and we’ve added three new variables to the view, $pageHeader, $metaKeywords, and $metaDescription. There’s just one more thing to do…

Add the dynamic metas to our layout

I’m not going to give a complete layout file here, since this is a basic step. Basically I just want to remind everyone to use CakePHP’s HTML helper for the meta keywords and meta description (the other two variables just need simple echo calls):

echo $html->meta('keywords', $metaKeywords);
echo $html->meta('description', $metaDescription);

And that’s it. Now you can have dynamic, cascading meta tags and page titles for your CakePHP application. As always, suggestions, improvements, critiques welcome.