Simple RESTful pagination with Symfony and Angularjs

Andrii Mishchenko
6 min readMar 24, 2018

--

Suppose we are going to display a list of tasks

What we currently have at our stack is

  • Symfony 3.4
  • FOSRestBundle 2.3
  • JMSSerializerBundle 2.3
  • Angular 1.6.8
  • Angular UI Router 1.0.15
  • Twitter Bootstrap

There is a state task-list which is pointed to TaskController

(function () {
'use strict';

var app = angular.module('taskApp');

app.config(function($stateProvider) {
$stateProvider.state('task-list', {
url: '/tasks',
controller: 'TaskController',
templateUrl: '/src/task/task-list.html',
});
});

app.controller('TaskController', function($scope, TaskResource) {
$scope.tasks = [];

TaskResource.list().$promise.then(function(response) {
$scope.tasks = response.tasks;
});
});
})();

task-list.html template is simple as this

<div class="task-list">
<div class="task" ng-repeat="task in tasks">
<h3>{{ task.title }}</h3>
</div>
</div>

and TaskResource is an angular resource with the only method list which retrieves the list of Tasks using RESTful API

(function () {
'use strict';

angular.module('taskApp').factory('TaskResource', function($resource, CONFIG) {
return $resource(null, {}, {
list: {
method: 'GET',
url: CONFIG.API_URL + '/tasks',
},
});
});
})();

On a backend we have a Symfony controller with the only method taskListAction which is responsible for handling GET request to /tasks URI.

<?php

namespace
AppBundle\Controller;

use FOS\RestBundle\Controller\Annotations\Get;
use FOS\RestBundle\Controller\FOSRestController;
use FOS\RestBundle\View\View;

/**
* Class TaskController
*
@package AppBundle\Controller
*/
class TaskController extends FOSRestController
{
/**
*
@Get("tasks")
*
*
@return View
*/
public function taskListAction()
{
$repo = $this->getDoctrine()->getRepository('AppBundle:Task');

$tasks = $repo->findAll();

$view = $this->view(['tasks' => $tasks]);
$view->getContext()->setGroups(['task_list']);

return $view;
}
}

This method returns an instance of FOS\RestBundle\View\View which contains the list of Tasks. Afterwards it is automatically handled by FOSRestBundle and converted into json format according to following configuration

AppBundle/Resources/config/serializer/Entity.Task.yml

AppBundle\Entity\Task:
exclusion_policy:
ALL
properties:
id:
expose:
true
groups:
- task_list
title:
expose:
true
groups:
- task_list

And Task entity itself looks like this

<?phpnamespace AppBundle\Entity;use Doctrine\ORM\Mapping as ORM;/**
* Class Thought
*
@package AppBundle\Entity
*
*
@ORM\Entity
*/
class Task
{
/**
*
@var int
*
*
@ORM\Id
* @ORM\Column(type="integer")
*
@ORM\GeneratedValue
*/
private $id;
/**
*
@var string
*
*
@ORM\Column(type="string", length=128)
*/
private $title;
/**
* Get id.
*
*
@return int
*/
public function getId()
{
return $this->id;
}
/**
* Set title.
*
*
@param string $title
*
*
@return Task
*/
public function setTitle($title)
{
$this->title = $title;
return $this;
}
/**
* Get title.
*
*
@return string
*/
public function getTitle()
{
return $this->title;
}
}

So /tasks endpoint returns json response in following format

{  
"tasks":[
{
"id":1,
"title":"Task 1"
},
{
"id":2,
"title":"Task 2"
},
{
"id":3,
"title":"Task 3"
}
]
}

Now the problem is obvious: if task list becomes huge we would like to split it into several pages and display only one page to user along with a pager bar. Fortunately major part of what we are going to do is already implemented by others, we just need to do a few things to make it work. First of all, there is a well-known KnpPaginatorBundle, which can be used for handling pagination on a backend. Install and enable the bundle and then modify TaskController::taskListAction

/**
*
@Get("tasks")
*
*
@param Request $request
*
@return View
*/
public function taskListAction(Request $request)
{
/** @var EntityManager $em */
$em = $this->getDoctrine()->getManager();
$qb = $em->createQueryBuilder()
->select('t')
->from('AppBundle:Task', 't');

$paginator = $this->get('knp_paginator');
$pagination = $paginator->paginate($qb, $request->get('page', 1), 5);

$view = $this->view($pagination);
$view->getContext()->setGroups(['task_list', 'pagination']);

return $view;
}

What has changed here? Instead of querying the whole list of tasks we create an instance of QueryBuilder and pass it to Knp\Component\Pager\Paginator::paginate method as first argument. Second argument is page passed as optionalGET parameter and representing current page number (first by default). Third one is page size which is hardcoded in this case but can be retrieved from request/configuration/elsewhere. What this method does is it modifies the QueryBuilder by appending limit and offset instructions (particularly calling it's setMaxResults and setFirstResult methods), executes the query and returns SlidingPagination object containing all necessary information about pages and items. Then it is passed to a view layer together with the context data — pay attention to $view->getContext()->setGroups([‘task_list’, ‘pagination’]); line.

Now to properly handle serialization of a new data we need to create configuration for SlidingPagination class. First we configure JMSSerializerBundle to be able to provide serialization configuration for classes in third-party bundles

jms_serializer:
metadata:
directories:
KnpPaginatorBundle:
namespace_prefix: "Knp\\Bundle\\PaginatorBundle"
path: "%kernel.root_dir%/KnpPaginatorBundle/Resources/config/serializer"

And the app/KnpPaginatorBundle/Resources/config/serializer/Pagination.SlidingPagination.yml file

Knp\Bundle\PaginatorBundle\Pagination\SlidingPagination:
exclusion_policy:
ALL
virtual_properties:
getItems:
expose:
true
serialized_name: items
type: array
groups:
- pagination
getPageCount:
expose:
true
serialized_name: page_count
type: integer
groups:
- pagination
getItemNumberPerPage:
expose:
true
serialized_name: items_per_page
groups:
- pagination
getTotalItemCount:
expose:
true
serialized_name: total_item_count
groups:
- pagination
getCurrentPageNumber:
expose:
true
serialized_name: current_page
groups:
- pagination

Actually we are not going to use all these properties in this example. But all of them contain reasonable information and might be useful for any additional needs. Notice that we specify pagination as a group for each property — it is necessary if we are going to apply pagination functionality to other resources and don’t want to modify this configuration each time we add a new serialization groups (user_list, tag_list etc).

Now both /tasks and /tasks?page=1 endpoint return data regarding the first page of tasks

{  
"current_page":1,
"items":[
{
"id":1,
"title":"Task 1"
},
{
"id":2,
"title":"Task 2"
},
{
"id":3,
"title":"Task 3"
},
{
"id":4,
"title":"Task 4"
},
{
"id":5,
"title":"Task 5"
}
],
"page_count":2,
"items_per_page":5,
"total_item_count":8
}

and /tasks?page=2 about the second etc.

This is mainly it regarding server-side. Now lets modify our frontend application to be able to work with a new backend.

First lets create a pagination component

// src/component/pagination/pagination.js
(function () {
'use strict';

angular.module('taskApp').component('pagination', {
templateUrl: 'src/component/pagination/pagination.html',
bindings: {
data: '<'
},
controller: function($location) {
var $ctrl = this;

$ctrl.$onChanges = function(changes) {
if (changes.data) {
$ctrl.pageCount = parseInt(changes.data.currentValue.page_count);
$ctrl.currentPage = parseInt(changes.data.currentValue.current_page);
$ctrl.pagesRange = getPagesRange($ctrl.pageCount, $ctrl.currentPage);
}
};

$ctrl.pageClick = function(page) {
$location.search({page: page}).replace();
};

var getPagesRange = function(pageCount, currentPage) {
function createList(min, max) {
var list = [];
for (var i = min; i <= max; i++) {
list.push(i);
}

return list;
}

if (pageCount <= 5) {
return createList(1, pageCount);
}

var left = currentPage - 2;
var right = currentPage + 2;

if (left < 1) {
right += Math.abs(left) + 1;
left = 1;
} else if (right > pageCount) {
var diff = Math.abs(right - pageCount);
right = pageCount;
left -= diff;
}

return createList(left, right);
};
},
});
})();

and the teamplate

<ul class="pagination" ng-if="$ctrl.pageCount > 1">
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === 1 }">
<a class="page-link" ng-click="$ctrl.pageClick(1)">&laquo;&laquo;</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === 1 }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.currentPage - 1)">&laquo;</a>
</li>
<li class="page-item" ng-repeat="page in $ctrl.pagesRange" ng-class="{ 'active': $ctrl.currentPage === page }">
<a class="page-link" ng-click="$ctrl.pageClick(page)">{{ page }}</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === $ctrl.pageCount }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.currentPage + 1)">&raquo;</a>
</li>
<li class="page-item" ng-class="{ 'disabled': $ctrl.currentPage === $ctrl.pageCount }">
<a class="page-link" ng-click="$ctrl.pageClick($ctrl.pageCount)">&raquo;&raquo;</a>
</li>
</ul>

What is going on here? In a few words this component accepts pagination object which we receive from server as an argument, calculates number or pages (max five) and displays it. Roughly speaking this component is javascript implementation of PaginationHelper::render method.

Bootstrap pagination component is used here. When any page button is clicked, pageClick method is called which updates current location with a new page parameter.

Now modify controller

(function () {
'use strict';

var app = angular.module('taskApp');

app.config(function($stateProvider) {
$stateProvider.state('task-list', {
url: '/tasks?page',
controller: 'TaskController',
templateUrl: '/src/task/task-list.html',
});
});

app.controller('TaskController', function($scope, TaskResource, $stateParams) {
$scope.pagination = {
current_page: $stateParams.page || 1,
};

TaskResource.list({page: $scope.pagination.current_page}).$promise.then(function(response) {
$scope.pagination = response;
});
});
})();

and a template

<div class="task-list">
<div class="task" ng-repeat="task in pagination.items">
<h3>{{ task.title }}</h3>
</div>

<pagination data="pagination"></pagination>
</div>

Nothing really to add here, just pay attention that page parameter is added to state definition and we use $scope.pagination object instead of $scope.tasks. Also $scope.pagination.current_page is passed to TaskResource.list method to add page information to a call of RESTful endpoint.

At this moment we have very simple but extensible implementation of RESTful resources pagination with Symfony and Angularjs.

--

--