Contact

FUxCon 2013


CakePHP 2

Installation

I downloaded CakePHP from the website http://cakephp.org and prepared it as described in its README.

You can get the code for this implementation from its github repository.

The repository contains a simple install script that loads all required components and sets up a simple demo configuration. Please follow the instructions in the file README.md contained in the repository.

There is also a virtual appliance based on VirtualBox which you can install for free locally on your computer or host on the web. This appliance has a website for each of the four web framworks readily installed. Please ask me about the appliance at the workshop or contact me directly.

Basic configuration

All configuration is done as PHP code in the files in directory app/Config. These are the files that we use:

File in app/ConfigMajor settings
core.php Basics:
  • Encryption seed
  • error and logging levels
  • cache and session configurations
bootstrap.php Registration of extensions
database.php Database type and credentials
routes.php Routes, the connection between URIs and controllers/actions

Modeling the problem domain - the models

In the Model-View-Controller pattern, Models define the types of objects in the problem domain. They also provide functionality to persist such objects in a relational database.

In CakePHP, models are implemented by PHP classes in the folder app/Model. Plugins can have there own models which then live in app/Plugin/plugin/Model. Model classes represent individual domain objects. They also allow to mainipulate collections of such objects. This can be through a set of powerful find methods or through own custom methods.

Model files generally don’t declare scalar database fields. CakePHP reads these from the database and caches them in a serialized structure in app/tmp/cache/models. However, models declare rules for field validation and one of the supported relationship types (1:1, 1:n, n:m). Models also declare special behavior like taggable or tree. a first approximation of models can be generated automatically by the command line tool cake, e.g.

app/Console/cake bake model Project

Our implementation uses two models in directory app/Model:

  • Project.php - projects listed on the start page
  • User.php - user accounts as owners of projects

Here is part of the project model from our CakePHP implementation:

<?php
class Project extends AppModel {
     // Taggable behavior comes from the Tags plugin, Containable is built-in 
     public $actsAs = array('Tags.Taggable', 'Containable');
     public $validate = array(
          'title' => array(
               'notempty' => array(
                    'rule' => array('notempty'),
                    'message' => 'Title must not be empty',
               ),
          ),
     );

     public $belongsTo = array(
          'User' => array(
               'className' => 'User',
               'foreignKey' => 'user_id',
          )
     );
}

In CakePHP, models can be enhanced by behaviors. There exist some standard behaviors like Containable, or Tree. Our implementation uses a contributed behavior Taggable to implement the tagging feature.

Business logic - the controllers

CakePHP uses entries in the file app/Config/routes.php to map request URIs to Controller methods.

Here is the route declared for our projects in app/Config/routes.php:

<?php
Router::connect('/', array('controller' => 'projects', 'action' => 'index'));  

This declares the homepage to be processed by controller method ProjectsController.index(). We rely on the default format for routes, e.g. /projects/view/32 for the other routes in our implementation.

Controllers are classes located in a file in app/Controller. Here is part of our ProjectsController.php:

<?php
class ProjectsController extends AppController {

  const NO_COL = 3;
  const PAGE_SIZE = 5;

  public $paginate = array(
    'Project' => array(
      'limit' => self::PAGE_SIZE,
    )
  );
 
  public $helpers = array('Thumbnail', 'Markdown.Markdown');

  public function index() {
    $this->Project->recursive = 0;
    $this->set('title_for_layout', 'Projects');


    $projects = $this->paginate('Project');
    $columns = array();
    foreach ($projects as $i => $project) {
      $col = $i % self::NO_COL;
      $columns[$col][] = $project;
    }
    $this->set(array(
      'columns' => $columns,
      'width' => 12 / self::NO_COL,
    ));
  }
  // … more actions
}

To make our example a bit more interesting, this code gets a paginated list of projects from the database and arranges this into columns. The controller uses $this->set() to pass variables to its associated view (see below) which then renders it to the client browser. Pagination is a built-in feature that gets configured by an instance variable $paginate and uses the method paginate() to get a page worth of project objects.

Controllers declare certain aspects of their operation as instance variables. Configuration shared with other controllers can be inherited from a common base class AppController. For example, our ProjectsController inherits the design appearance, and components needed for authentication from this base class:

<?php
class AppController extends Controller {
  public $theme = 'Bootstrap';
  public $components = array(
    'DebugKit.Toolbar',
    'Session', 
    'Auth' => array(
      'loginRedirect' => array('controller' => 'projects', 'action' => 'index'),
      'logoutRedirect' => array('controller' => 'projects', 'action' => 'index'),
      'authorize' => array('Controller'),
      'element' => 'message-auth',
    )
  );

  public function beforeFilter() {
    $this->Auth->allow('index', 'view');
  }
  // … more common methods
}

Appearance - the views

Views in CakePHP can be arranged in themes. We use this to define our design-specific templates file in directory app/View/Themed/Bootstrap. Each controller action has a corresponding view template, e.g. the file app/View/Projects/index.ctp. CakePHP uses plain PHP files as templates. Here is some example code from index.ctp:

  <?php foreach ($columns as $column): ?>
    <div class="span<?php echo $width; ?>">
    <?php foreach ($column as $project): ?>
      <div class="project">
        <h4>
          <?php echo $this->Html->link($project['Project']['title'], array('action' => 'view', $project['Project']['id'])); ?>
        </h4>
        <?php echo $this->Html->link(
          $this->Thumbnail->render('project/' . $project['Project']['id'] . '.jpg', array(
            'width' => 200, 'height' => 200, 'resizeOption' => 'auto',
          )), array('action' => 'view', $project['Project']['id']), array('escape' => FALSE, 'class' => 'thumbnail')); ?>
        <p class="about"><?php echo $this->Text->truncate($project['Project']['about'], /*length*/100 + rand(1, 100), array('exact' => FALSE)); ?></p>
      </div>
    <?php endforeach; ?>
    </div>
  <?php endforeach; ?>

<?php echo $this->element('paginate'); ?> 

$columns is the paginated list of projects, arranged into columns and passed from the controller. $this->Html is a view helper that we use here to generate links. $this->Thumbnail is a custom helper. This is defined in app/View/Helper/ThumbnailHelper.php and declared in app/Controller/AppController.php as

<?php
class ProjectsController extends AppController { 
  public $helpers = array('Thumbnail', 'Markdown.Markdown');
  // …
}

Markdown formatting for project detail pages is provided by the Markdown plugin. The helpers Html and Text are always available and don’t have to be declared this way.

To provide a common design, the output of templates is generally embedded into layout files. The basic structure of our layout file app/View/Themed/Bootstrap/Layouts/default.ctp looks like this:

<!DOCTYPE html>
<html>
<head>
   <?php echo $this->Html->charset(); ?>
   echo $this->Html->css('/bootstrap/css/bootstrap.min');
   echo $this->Html->css('styles');
   ?>
</head>
<body class="<?php echo strtolower($this->name . '-' . $this->action); ?>">
   <div class="container-narrow">
      <!-- … more markup -->
      <?php echo $this->Session->flash(); ?>
      <?php echo $this->fetch('content'); ?>
   </div><!-- .container-narrow -->
</body>
</html>

The content of the view template is inserted by the call to

<?php
$this->fetch('content');

The Session helper outputs to feedback to the user like e.g. a success message when saving a project.

User accounts & security

CakePHP provides a flexible Auth component that does most of the work required for managing user accounts. It is declared in the controller or the common base class AppController (excerpt from file app/Controller/AppController.php):

<?php
class AppController extends Controller {
  public $theme = 'Bootstrap';
  public $components = array(
    'DebugKit.Toolbar',
    'Session', 
    'Auth' => array(
      'loginRedirect' => array('controller' => 'projects', 'action' => 'index'),
      'logoutRedirect' => array('controller' => 'projects', 'action' => 'index'),
      'authorize' => array('Controller'),
      'element' => 'message-auth',
    )
  );

  public function beforeFilter() {
    $this->Auth->allow('index', 'view');
  }

  public function isAuthorized($user) {
    // Admin can access every action
    if (isset($user['role']) && $user['role'] === 'admin') {
      return TRUE;
    }

    // Default deny
    return FALSE;
  }
}

Controllers overwrite allowed access by overwriting the isAuthorized() method:

<?php
class ProjectsController
{
   public function isAuthorized($user, $action = NULL) {
    // All registered users can add projects
    if ($this->action === 'add') {
      return true;
    }

    if (!$action) {
      $action = $this->action;
    }

    // The owner of a project can edit and delete it
    if (in_array($action, array('edit', 'delete'))) {
      $projectId = $this->request->params['pass'][0];
      if ($this->Project->isOwnedBy($projectId, $user['id'])) {
        return TRUE;
      }
    }
    return parent::isAuthorized($user);
  }

Apart from this simple, form-based authentication with controller-based authorization, other variants are possible as documented in the Cake Book.

Assiging topics to projects with tagging

The CakePHP implementation uses the Tags Plugin to implement tagging on projects. This plugin implements a new behavior on the Project model. This is the top of our model in file app/Model/Project.php

<?php
/**
 * Project Model
 *
 * @property User $User
 */
class Project extends AppModel {
  public $actsAs = array('Tags.Taggable', 'Containable');
  // more domain model goodness
}

The plugin adds two new tables tags and tagging and thus implements the m:n relationship between projects and tags. The plugin relies on a field tags in the model which it automatically links to. A user can enter tags as a comma separated list in the edit form which is converted to related tags behind the scenes by the plugin behavior:

CakePHP tag display

… is entered as

CakePHP tag entry

Picture uploading and scaling

Our CakePHP implementation treats pictures and derived formats a bit like stepchildren. To keep the implementation small, pictures are not represented by model classes but are rather uploaded into the file system directly. Also, we only support JPEG as upload format.

The bad thing about this simplistic approach is that in order to check if a project has an uploaded picture, we have to check the file system, a potentially slow operation. Also, we save all pictures in a single directory, a thing you wouldn’t want to do if you have more than a few thousand pictures because then file system access will become even slower.

Controllers don’t do anything special to handle pictures.

The views use a special helper in file app/View/Helper/ThumbnailHelper.php to create derived images in the fly. These are then saved in the file system. Through a proper rewrite configuration, the web server delivers these cached derivatives directly, without going through the process of re-creating the derivatives again.

The helper uses special directories to save the derivatives:

CakePHP thumbnails

The same helper is used in the edit forms to show an image thumbnail of an already existing picture:

<div class="pull-right thumbnail">
	<?php
  echo $this->Thumbnail->render('project/' . 1 . '.jpg', array(
    'width' => 160, 
    'height' => 160, 
    'resizeOption' => 'auto',
  )); 
  ?>
</div>
<?php
echo $this->Form->input('picture', array(
  'type' => 'file',
  'id' => 'picture-field'
));
?>

The handling of file uploads is done in the Project model class in the before- and afterSave callbacks:

<?php
/**
 * Before saving the project, check an uploaded picture
 */
public function beforeSave($options = array()) {
  if (!isset($this->data[$this->alias]['picture'])) {
    return TRUE;
  }
  $file = $this->data[$this->alias]['picture'];
  if ($file['error'] === UPLOAD_ERR_NO_FILE) {
    return TRUE;
  }
  if ($file['error'] !== UPLOAD_ERR_OK) {
    return FALSE;
  }
  if (strpos($file['type'], 'image/jpeg') !== 0) {
    return FALSE;
  }
  return TRUE;
}

/**
 * After saving the project, save an uploaded picture
 */
public function afterSave($created) {
  if (!isset($this->data[$this->alias]['picture'])) {
    return;
  }
  $file = $this->data[$this->alias]['picture'];

  if ($file['error'] === UPLOAD_ERR_OK) {
    if (!move_uploaded_file($file['tmp_name'], IMAGES . 'project' . DS . $this->id . '.jpg')) {
      throw new Exception("Failed to move file: " 
        . posix_strerror(posix_get_last_error()));
    }
  }
}

A picture upload is checked before saving a project and actually stored after saving when we have the project ID.

Forms and CRUD - Creating and updating projects

Forms in CakePHP closely cooperate with models. Controllers provide the glue between the two. Controller code can be generated by the CakePHP console:

cd app
../lib/Cake/Console/cake bake

The generated controller does have methods for Create, Read, Update and Delete (CRUD) operations. Here is the generated method to add a project:

<?php
class ProjectsController
{
  /**
   * edit method
   *
   * @throws NotFoundException
   * @param string $id
   * @return void
   */
	public function edit($id = null) {
		if (!$this->Project->exists($id)) {
			throw new NotFoundException(__('Invalid project'));
		}
		if ($this->request->is('post') || $this->request->is('put')) {
      unset($this->request->data['user_id']);
			if ($this->Project->save($this->request->data)) {
				$this->Session->setFlash(__('The project has been saved'), 'message-success');
				$this->redirect(array('action' => 'view', $id));
			} else {
				$this->Session->setFlash(__('The project could not be saved. Please, try again.'), 'message-error');
			}
		} else {
			$options = array('conditions' => array('Project.' . $this->Project->primaryKey => $id));
			$this->request->data = $this->Project->find('first', $options);
		}
		$users = $this->Project->User->find('list');
		$tags = $this->Project->Tag->find('list');
		$this->set(compact('users', 'tags'));

		$this->set('pageTitle', __('Edit project'));
	}
}

We have rearranged the edit template somewhat:

CakePHP edit form

Here is a part of the file app/View/Projects/edit.ctp that creates this form:

<div class="pull-right thumbnail">
	<?php
  echo $this->Thumbnail->render('project/' . $form->field('id') . '.jpg', array(
    'width' => 160, 
    'height' => 160, 
    'resizeOption' => 'auto',
  )); 
  ?>
</div>
<?php
echo $this->Form->input('picture', array(
  'type' => 'file',
  'id' => 'picture-field'
));
?>

Text formatting with Markdown

Out CakePHP implementation uses an external Markdown plugin. This includes a view helper that is used on the project detail pages process the Markdown markup. Here is an excerpt from app/View/Projects/view.ctp:

<div class="span4">
    <h1 class="title-content"><?php echo $project['Project']['title']; ?></h1>
    <div class="about-content"><?php echo Markdown($project['Project']['about']); ?></div>
</div>

The second place where text processing occurs is on project lists. Here we use the built-in Text helper to shorten the teaser texts. Here is an excerpt from app/View/Projects/index.ctp:

<p class="about"><?php echo $this->Text->truncate($project['Project']['about'], /*length*/100, array('exact' => FALSE)); ?></p>

Test data generation

To generate test data from our scenario step definitions, the CakePHP implementation provides a custom, ancillary method createProjects() in controller BehatController in file app/Controller/BehatController.php. In the step definition, we call this before running a test suite to initialize the test data:

<?php
class FeatureContext extends MinkContext
{
  const PROJECT_COUNT = 7;

  /**
   * @BeforeSuite
   */
  public static function prepare(SuiteEvent $event)
  {
    $params = $event->getContextParameters();
    $baseUrl = $params['base_url'];

    /* 
     * Initialize test database with more projects than 
     * what fits on a page
     */
    $config = json_decode(file_get_contents($baseUrl . 'behat/config'));
    if (!$config) {
      throw new Exception('Could not get config from ' . $baseUrl);
    }
    $count = $config->pageSize * 3 + intval($config->pageSize / 2);
    $result = json_decode(file_get_contents($baseUrl . 'behat/createProjects/' . self::PROJECT_COUNT));

    if ($result->status != "OK") {
      throw new Exception($result->message);
    }
  }
}

On the server side, there was some ugly fiddling required to make the creation of demo picture uploads work.

<?php
class BehatController {
  function createProjects($count = 0)
  {
    $tmp = tempnam('/tmp', 'testimage.');
    $picture = array(
      'tmp_name' => $tmp,
      'type' => 'image/jpeg',
      'error' => 'behat_test',
    );

    for ($i = 1; $i <= $count; $i++) {
      copy(IMAGES . 'testimage.jpg', $tmp);

      $this->Project->create();
      if (!$this->Project->save(array(
        'picture' => $picture,
      ))) {
        throw new Exception("SAVE failed");
      }
    }
  }
}

In the Project model, this needs to be a special case as in this case the image is not an uploaded one:

<?php
class Project extends AppModel {

  function beforeSave($options = array()) {
    $file = $this->data[$this->alias]['picture'];

    if ($file['error'] === UPLOAD_ERR_NO_FILE 
      || $file['error'] === 'behat_test') {
      return TRUE;
    }
  }
  
  function afterSave($created) {
    $file = $this->data[$this->alias]['picture'];
    if ($file['error'] === 'behat_test') {
      if (!rename($file['tmp_name'], IMAGES . 'project' . DS . $this->id . '.jpg')) {
        throw new Exception("Failed to move file: " 
          . posix_strerror(posix_get_last_error()));
      }
    }
    else
    if ($file['error'] === UPLOAD_ERR_OK) {
      if (!move_uploaded_file($file['tmp_name'], IMAGES . 'project' . DS . $this->id . '.jpg')) {
        throw new Exception("Failed to move file: " 
          . posix_strerror(posix_get_last_error()));
      }
    }
  }
}


comments powered by Disqus