Binocode

Symfony2 pagination with controller as a service and data sorting

by Pawel Barcik

Symfony2 doesn't provide any good pagination tools out of the box so to solve this quite important problem I have created my own solution.

I needed to be able to paginate data based on requested page and per_page parameters through GET and POST requests. Second important thing was to be able to sort displayed data by id, title, etc.

I could have created some Paginator class but I wanted to be able to tweak each part of the process as I see fit on a per controller basis and I wanted to be able to access necessary functions through very short function calls directly in a controller.

I decided to extend all my controllers with necessary helper functions which would be available across all of them whenever I need to paginate something.

First problem was extending the controllers. Symfony2 docs page http://symfony.com/doc/2.0/book/controller.html shows you how to create controllers in a vary common way, but what it doesn't say is that in this way you won't be able to extend all controllers without running into various problems with access to the service container and Doctrine2 EntityManager which is needed to work with your data.

The solution to above problems is to use controllers as a service which allows you to use class inheritance, interfaces etc. in any way you want.

Application setup

Lets assume the application is configured as follows:

  • bundle is located in src/Foo/DemoBundle
  • all controllers will extend ApplicationController class which extends Symfony\Bundle\FrameworkBundle\Controller\Controller
  • pagination template is located in here src/Foo/DemoBundle/Resources/views/Pagination/pagination.html.twig
  • all necessary twig macros will be stored in here src/Foo/DemoBundle/Resources/views/global_macros.html.twig

Controller as a Service

To set up your controller as a service you need to modify your routing and service configuration.

Foo/DemoBundle/Resources/config/services.yml file should be modified as follows:

parameters:
  demo.products_controller.class: Foo\DemoBundle\Controller\ProductsController
services:
#  demo.example:
#    class: %demo.example.class%
#    arguments: [@service_id, "plain_value", %parameter%]
  demo.products_controller:
    class: %demo.products_controller.class%
    arguments: []
    calls:
      - [ setContainer, ["@service_container"] ]
     - [ init, [] ]

the "- [ init, [] ]" part will be used to trigger function call ProductsController.init() each time the controller is being instantiated. This function will take care of the configuration of all controllers but we will get back to this later on.

Now we need to update the routing config app/config/routing.yml file:

products:
  pattern:  /products
  defaults: { _controller: demo.products_controller:indexAction }
  requirements: { _method: get }

If the controller would be set up as a "normal" controller , not a service , the configuration would look like this:

products:
  pattern:  /products
  defaults: { _controller: FooDemoBundle:Products:index }
  requirements: { _method: get }

As you can see the controller reference notation is different.

To be able to add helper functions to all controllers we need to create ApplicationController which will be extended by all other controllers. This controller will also allow us to access merged GET and POST request parameters which will simplify a lot any request processing. ApplicationController will provide both pagination and data sorting helpers. As an extra feature to the data sorting and pagination we will make it persistent per each controller which allows you to go to any other page and then go back and have your chosen sorting and pagination options applied again.

<?php

namespace Foo\DemoBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\ParameterBag;

class ApplicationController extends Controller {

  protected $manager;
  protected $perPageDefault;

  public function init() {
    $this->manager = $this->getDoctrine()->getEntityManager();
    $request = $this->getRequest();
    $this->params = new ParameterBag(array_merge($request->query->all(), $request->request->all()));
    $this->perPageDefault = 10;
  }

  protected function page() {
    $page = $this->params->get("page")? intval($this->params->get("page")): 1;
    if($page <= 0) {
      $page = 1;
    }
    return $page;
  }

  protected function perPage() {
    $session = $this->get("session");
    $pagination = $session->get("pagination");
    if(!is_array($pagination)) {
      $pagination = array();
    }

    if($this->params->get("per_page")) {
      $perPage = intval($this->params->get("per_page"));
    } elseif(isset($pagination["" . get_class($this)]["per_page"])) {
      $perPage = $pagination["" . get_class($this)]["per_page"];
    } else {
      $perPage = $this->perPageDefault;
    }

    if($perPage <= 0) {
      $perPage = $this->perPageDefault;
    }

    $pagination["" . get_class($this)]["per_page"] = $perPage;
    $session->set("pagination", $pagination);

    return $perPage;
  }

  protected function orderBy($default = "id") {
    $orderByData =  explode("-", "" . $this->params->get("order_by"));
    if($orderByData && $orderByData[0]) {
      return $orderByData[0];
    } else {
      return $default;
    }
  }

  protected function orderDirection($default = "asc") {
    $orderByData =  explode("-", "" . $this->params->get("order_by"));
    $direction = $default;
    if($orderByData && isset($orderByData[1]) && $orderByData[1] ) {
      if($orderByData[1] == "up") {
        $direction = "desc";
      } else {
        $direction = "asc";
      }
    }
    return $direction;
  }

}

Now we need our ProductsController with indexAction

<?php

namespace Foo\DemoBundle\Controller;

use Foo\DemoBundle\Entity\Product;
use Symfony\Component\HttpFoundation\Request;


class ProductsController extends ApplicationController
{

  function __construct() {

  }

  function indexAction() {

    $offset = ($this->page() - 1) * $this->perPage();

    $queryBuilder = $this->manager
                  ->getRepository('FooDemoBundle:Product')
                  ->createQueryBuilder('p')
                  ->orderBy('p.' . $this->orderBy(), $this->orderDirection());

    $rowCount = $queryBuilder->select($queryBuilder->expr()->count('p.id'))
                             ->getQuery()->getSingleResult();

    $query = $queryBuilder
              ->select('p')
              ->setFirstResult( $offset )
              ->setMaxResults( $this->perPage() )
              ->getQuery();
    $products = $query->getResult();


    return $this->render('FooDemoBundle:Products:index.html.twig',
      array(
        "products" => $products,
        "params" => $this->params,
        "page" => $this->page(),
        "per_page" => $this->perPage(),
        "page_count" => (int)ceil($rowCount[1] / $this->perPage()),
        "order_by" => $this->orderBy(),
        "order_direction" => $this->orderDirection()
      )
    );
  }

  protected function orderBy($default = "id") {
    $orderBy = parent::orderBy($default);
    if(in_array($orderBy , array( "id", "title" ) )) {
      return $orderBy;
    } else {
      return $default;
    }

  }

Our ProductsController is overwriting orderBy function to set up default order and secure/limit the order_by parameter options.

Pagination template, macros and sorting

Now that the controllers are all set we need to create a Twig macro which will generate for us the necessary pagination widget. This macro is allowing you to specify your own pagination template so customizing the widget is very easy.

All the macros are located in Foo/DemoBundle/Resources/views/global_macros.html.twig

{% macro paginate(context, pagination_url, pagination_template, page_count, current_page, per_page, side_pages) %}
{% spaceless %}
  {% set side_pages = side_pages|default(5) %}
  {% set pagination_url = pagination_url|default("#") %}

  {% if current_page > page_count %}
    {% set current_page = page_count %}
  {% endif%}

  {% set left_start_page = current_page -  side_pages %}

  {% if left_start_page < 1 %}
    {% set left_start_page = 1 %}
  {% endif%}

  {% set left_pages = current_page - left_start_page %}

  {% set right_end_page = current_page +  side_pages %}

  {% if right_end_page > page_count %}
    {% set right_end_page = page_count %}
  {% endif%}

  {% set right_pages = right_end_page - current_page %}

  {% include pagination_template %}

{% endspaceless %}
{% endmacro%}

Next thing to do is to create pagination template

<ul class='pagination'>

  {% if left_pages > 0 %}
    <li class='first'>
      <a href="{{pagination_url}}?{{ query_string({ "page": 1 }, context.params.all()) }}">&laquo; First</a>
    </li>
    <li class='prev'>
      <a href="{{pagination_url}}?{{ query_string({ "page": (current_page - 1) }, context.params.all()) }}">&lsaquo; Prev</a>
    </li>
    {% if current_page - left_pages > 1 %}
    <li class='page gap'>...</li>
    {% endif %}
    {% for page_id in (current_page - left_pages)..(current_page - 1) %}
      <li class='page'>
        <a href="{{pagination_url}}?{{ query_string({ "page": page_id }, context.params.all()) }}" rel="prev">{{page_id}}</a>
      </li>
    {% endfor%}

  {% endif %}
  <li class='page active'>
    {{current_page}}
  </li>
  {% if right_pages > 0 %}
    {% for page_id in (current_page + 1)..(current_page + right_pages) %}
      <li class='page'>
        <a href="{{pagination_url}}?{{ query_string({ "page": page_id }, context.params.all()) }}" rel="next">{{page_id}}</a>
      </li>
    {% endfor%}
    {% if current_page + right_pages < page_count %}
    <li class='page gap'>...</li>
    {% endif %}
    <li class='next'>
      <a href="{{pagination_url}}?{{ query_string({ "page": (current_page + 1) }, context.params.all()) }}"  rel="next">Next &rsaquo;</a>
    </li>
    <li class='last'>
      <a href="{{pagination_url}}?{{ query_string({ "page": (page_count) }, context.params.all()) }}">Last &raquo;</a>
    </li>
  {% endif %}
</ul>

As you can see the above template is using my custom query_string Twig function which is merging new request parameters with the current ones and generates a query string, which is useful for paginating search results. To create your own custom Twig function you need to create Twig Extension but I won't be showing this in this post.

To be able to sort data easily we will need two additional macros

{% macro order_by_string(order_by, current_order_by, current_order_direction, default_direction) %}
{% spaceless %}
  {% if current_order_by == order_by %}
    {% if current_order_direction == "desc" %}
      {% set order_direction = "down" %}
    {% else %}
      {% set order_direction = "up" %}
    {% endif %}
  {% else %}
    {% set order_direction = default_direction|default("down") %}
  {% endif%}
  {{ order_by }}-{{ order_direction }}
{% endspaceless %}
{% endmacro%}

{% macro order_by_class(order_by, order_by_param) %}
{% spaceless %}
  {% set css_class = "" %}
  {% if order_by_param == order_by ~ "-up" %}
    {% set css_class = "headerSortUp" %}
  {% endif%}
  {% if order_by_param == order_by ~ "-down" %}
    {% set css_class = "headerSortDown" %}
  {% endif%}
  {{css_class}}
{% endspaceless %}
{% endmacro%}

Both those macros will help us to create appropriate order_by values for eg:

title-up
title-down

depending on the current state which allows us to easily toggle between how the data should be ordered.

With all this set up we can create the view template now

{% include 'FooDemoBundle::global_macros.html.twig' as global_macros %}

<ul class="">
  <li>
    Per page:
  </li>
  <li>
    <a href="?{{ query_string({ "page": 1, "per_page": 10  }, params.all()) }}">10</a>
  </li>
  <li>
    <a href="?{{ query_string({ "page": 1, "per_page": 20  }, params.all()) }}">20</a>

  </li>
  <li>
    <a href="?{{ query_string({ "page": 1, "per_page": 50  }, params.all()) }}">50</a>
  </li>
  <li>
    <a href="?{{ query_string({ "page": 1, "per_page": 100  }, params.all()) }}">100</a>
  </li>
</ul>

{{ global_macros.paginate(_context, path("products"), pagination_template, page_count, page, per_page, 3) }}
<table id="products-table">
  <thead>
    <tr>
      <th class="{{ global_macros.order_by_class("id", params.get("order_by")) }}">
        <a href="{{ url("products") }}?order_by={{ global_macros.order_by_string("id", order_by, order_direction) }}">product ID</a>
      </th>
      <th class="{{ global_macros.order_by_class("title", params.get("order_by")) }}">
        <a href="{{ url("products") }}?order_by={{ global_macros.order_by_string("title", order_by, order_direction) }}">title</a>
      </th>
    </tr>
  </thead>
  <tbody>
    {% for element in products %}
    <tr>
      <td class="id-column" >
        {{element.id}}
      </td>
      <td class="title-column">
        {{element.title}}
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>
{{ global_macros.paginate(_context, path("products"), pagination_template, page_count, page, per_page, 3) }}

Final thoughts

As you can see, this pagination code is very flexible and can be customized to fit any purpose , especially if you need to create paginated search results which can be bookmarked.

Certain parts of this code have been ported from Ruby on Rails application that I have created before which shows that this solution should be portable to any other MVC and templating framework.

EDIT: Updated the pagination macro and template to use "pagination_url" passed as parameter as intended , because the template should be reusable.


← Back to Overview

comments powered by Disqus