I’ll try to make it fast. So, for those who just want to see the code, you can clone it from the github repository. Feel free to fork it, improve it, add issues, etc.

Now, for those who are still reading this article n_n. A few time I have been asked to add some filtering and basic search on paginated lists baked with cakephp. The principle is quite simple, we need a form with some few inputs or selects (our filters), and pass those parameters to the $paginate variable on the controller.

We need to have those filter parameters on the url because if we only work with POST parameters we will lose all of our filters as soon as we change the page, obviously because our pagination numbers are just links. So the first thing to do is to transform the POST sended by the form into named parameters. We could (maybe) set the form method attribute to “get” but I prefer not to, because we could end up with urls that have both named parameters and GET parameters. Witch looks ugly “/test/page:1/sort:title/?parameter=value”

The Data-model

It’s a simple model to paginate a list of movies by the genre or the director. It will include a basic search on the title and description of the movie

The View (index.ctp)


<?php
$this->Html->css('base', null, array('inline' => false));
?>
<div class="movies index">
  <h2><?php echo __('Movies'); ?></h2>
  
  <div class="filters">
    <h3>Filters</h3>
    <?php
    // The base url is the url where we'll pass the filter parameters
    $base_url = array('controller' => 'movies', 'action' => 'index');
    echo $this->Form->create("Filter",array('url' => $base_url, 'class' => 'filter'));
    // add a select input for each filter. It's a good idea to add a empty value and set
    // the default option to that.
    echo $this->Form->input("genre_id", array('label' => 'Genre', 'options' => $genres, 'empty' => '-- All genres --', 'default' => ''));
    echo $this->Form->input("director_id", array('label' => 'Director', 'options' => $directors, 'empty' => '-- All directors --', 'default' => ''));
    // Add a basic search 
    echo $this->Form->input("search", array('label' => 'Search', 'placeholder' => "Search..."));

    echo $this->Form->submit("Valider");

    // To reset all the filters we only need to redirect to the base_url
    echo "<div class='submit actions'>";
    echo $this->Html->link("Reset",$base_url);
    echo "</div>";
    echo $this->Form->end();
    ?>
  </div>

  <table cellpadding="0" cellspacing="0">
  <tr>
      <th><?php echo $this->Paginator->sort('id'); ?></th>
      <th>Genre</th>
      <th>Director</th>
      <th><?php echo $this->Paginator->sort('title'); ?></th>
      <th>Description</th>
      <th class="actions"><?php echo __('Actions'); ?></th>
  </tr>
  <?php foreach ($movies as $movie): ?>
  <tr>
    <td><?php echo h($movie['Movie']['id']); ?>&nbsp;</td>
    <td>
      <?php echo $movie['Genre']['label']; ?>
    </td>
    <td>
      <?php echo $movie['Director']['name']; ?>
    </td>

    <td><?php echo $this->Text->highlight(h($movie['Movie']['title']), $search); ?>&nbsp;</td>
    <td><?php echo $this->Text->highlight(h($movie['Movie']['description']), $search); ?>&nbsp;</td>
    <td class="actions">
      <?php echo $this->Html->link(__('View'), array('action' => 'view', $movie['Movie']['id'])); ?>
      <?php echo $this->Html->link(__('Edit'), array('action' => 'edit', $movie['Movie']['id'])); ?>
      <?php echo $this->Form->postLink(__('Delete'), array('action' => 'delete', $movie['Movie']['id']), null, __('Are you sure you want to delete # %s?', $movie['Movie']['id'])); ?>
    </td>
  </tr>
<?php endforeach; ?>
  </table>
  <p>
  <?php
  echo $this->Paginator->counter(array(
  'format' => __('Page {:page} of {:pages}, showing {:current} records out of {:count} total, starting on record {:start}, ending on {:end}')
  ));
  ?>  </p>
  <div class="paging">
  <?php
    echo $this->Paginator->prev('< ' . __('previous'), array(), null, array('class' => 'prev disabled'));
    echo $this->Paginator->numbers(array('separator' => ''));
    echo $this->Paginator->next(__('next') . ' >', array(), null, array('class' => 'next disabled'));
  ?>
  </div>
</div>
<div class="actions">
  <h3><?php echo __('Actions'); ?></h3>
  <ul>
    <li><?php echo $this->Html->link(__('New Movie'), array('action' => 'add')); ?></li>
    
  </ul>
</div>

The Controller (MoviesController.php)


App::uses('AppController', 'Controller');
App::uses('Sanitize', 'Utility');
/**
 * Movies Controller
 *
 * @property Movie $Movie
 */
class MoviesController extends AppController {

/**
 * index method
 *
 * @return void
 */
  public function index() {
    $conditions = array();
    //Transform POST into GET
    if(($this->request->is('post') || $this->request->is('put')) && isset($this->data['Filter'])){
      $filter_url['controller'] = $this->request->params['controller'];
      $filter_url['action'] = $this->request->params['action'];
      // We need to overwrite the page every time we change the parameters
      $filter_url['page'] = 1;

      // for each filter we will add a GET parameter for the generated url
      foreach($this->data['Filter'] as $name => $value){
        if($value){
          // You might want to sanitize the $value here
          // or even do a urlencode to be sure
          $filter_url[$name] = urlencode($value);
        }
      } 
      // now that we have generated an url with GET parameters, 
      // we'll redirect to that page
      return $this->redirect($filter_url);
    } else {
      // Inspect all the named parameters to apply the filters
      foreach($this->params['named'] as $param_name => $value){
        // Don't apply the default named parameters used for pagination
        if(!in_array($param_name, array('page','sort','direction','limit'))){
          // You may use a switch here to make special filters
          // like "between dates", "greater than", etc
          if($param_name == "search"){
            $conditions['OR'] = array(
              array('Movie.title LIKE' => '%' . $value . '%'),
                array('Movie.description LIKE' => '%' . $value . '%')
            );
          } else {
            $conditions['Movie.'.$param_name] = $value;
          }         
          $this->request->data['Filter'][$param_name] = $value;
        }
      }
    }
    $this->Movie->recursive = 0;
    $this->paginate = array(
      'limit' => 8,
      'conditions' => $conditions
    );
    $this->set('movies', $this->paginate());

    // get the possible values for the filters and 
    // pass them to the view
    $genres = $this->Movie->Genre->find('list');
    $directors = $this->Movie->Director->find('list');
    $this->set(compact('genres', 'directors'));

    // Pass the search parameter to highlight the text
    $this->set('search', isset($this->params['named']['search']) ? $this->params['named']['search'] : "");
  }

/**
 * view method
 *
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function view($id = null) {
    if (!$this->Movie->exists($id)) {
      throw new NotFoundException(__('Invalid movie'));
    }
    $options = array('conditions' => array('Movie.' . $this->Movie->primaryKey => $id));
    $this->set('movie', $this->Movie->find('first', $options));
  }

/**
 * add method
 *
 * @return void
 */
  public function add() {
    if ($this->request->is('post')) {
      $this->Movie->create();
      if ($this->Movie->save($this->request->data)) {
        $this->Session->setFlash(__('The movie has been saved'));
        $this->redirect(array('action' => 'index'));
      } else {
        $this->Session->setFlash(__('The movie could not be saved. Please, try again.'));
      }
    }
    $genres = $this->Movie->Genre->find('list');
    $directors = $this->Movie->Director->find('list');
    $this->set(compact('genres', 'directors'));
  }

/**
 * edit method
 *
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function edit($id = null) {
    if (!$this->Movie->exists($id)) {
      throw new NotFoundException(__('Invalid movie'));
    }
    if ($this->request->is('post') || $this->request->is('put')) {
      if ($this->Movie->save($this->request->data)) {
        $this->Session->setFlash(__('The movie has been saved'));
        $this->redirect(array('action' => 'index'));
      } else {
        $this->Session->setFlash(__('The movie could not be saved. Please, try again.'));
      }
    } else {
      $options = array('conditions' => array('Movie.' . $this->Movie->primaryKey => $id));
      $this->request->data = $this->Movie->find('first', $options);
    }
    $genres = $this->Movie->Genre->find('list');
    $directors = $this->Movie->Director->find('list');
    $this->set(compact('genres', 'directors'));
  }

/**
 * delete method
 *
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function delete($id = null) {
    $this->Movie->id = $id;
    if (!$this->Movie->exists()) {
      throw new NotFoundException(__('Invalid movie'));
    }
    $this->request->onlyAllow('post', 'delete');
    if ($this->Movie->delete()) {
      $this->Session->setFlash(__('Movie deleted'));
      $this->redirect(array('action' => 'index'));
    }
    $this->Session->setFlash(__('Movie was not deleted'));
    $this->redirect(array('action' => 'index'));
  }
}

A few notes

  • I’m using a very simple search here, just a like. But all I wanted was to show you the basic algorithm for filtering.
  • In order to respect the DRY principle, you might want to put this code inside a component and a helper
  • You could use some rewrite rules to make beautiful urls to improve the SEO.
  • In this example I am only using 1xN relationships. If you want to filter NxN relations, you’ll need to work a little more on the controller side (making manual joins).

Hope this helps :). Please leave a comment if something is not working correctly on the code or post an issue on github. I’ll write a few unit test later and push it