Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Pagerfanta Pagination

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Installing Pagerfanta

To handle pagination, we're going to install the WhiteOctoberPagerfantaBundle. To install the bundle, run:

composer require white-october/pagerfanta-bundle

Pagerfanta is a great library for pagination, whether you're doing things on the web or building an API. While we're waiting, enable the bundle in AppKernel:

... lines 1 - 5
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = array(
... lines 11 - 20
new WhiteOctober\PagerfantaBundle\WhiteOctoberPagerfantaBundle(),
);
... lines 23 - 33
}
... lines 35 - 39
}

And that's it for setup: no configuration needed. Now just wait for Composer, and we're ready!

Setting up the Query Builder

Open up ProgrammerController and find the listAction() that we need to work on. Pagination is pretty easy: you basically need to tell the pagination library what page you're on and give it a query builder. Then, you can use it to fetch the correct results for that page.

To read the page query parameter, type-hint the Request argument and say $page = $request->query->get('page', 1);. The 1 is the default value in case there is no query parameter:

... lines 1 - 20
class ProgrammerController extends BaseController
{
... lines 23 - 74
/**
* @Route("/api/programmers")
* @Method("GET")
*/
public function listAction(Request $request)
{
$page = $request->query->get('page', 1);
... lines 82 - 102
}
... lines 104 - 202
}

Go Deeper!

You could also use $request->query->getInt('page', 1) instead of get() to convert the page query parameter into an integer. See accessing request data for other useful methods.

Next, replace $programmers with $qb, standing for query builder. And instead of calling findAll(), use a new method called findAllQueryBuilder():

... lines 1 - 20
class ProgrammerController extends BaseController
{
... lines 23 - 78
public function listAction(Request $request)
{
$page = $request->query->get('page', 1);
$qb = $this->getDoctrine()
->getRepository('AppBundle:Programmer')
->findAllQueryBuilder();
... lines 86 - 102
}
... lines 104 - 202
}

That doesn't exist yet, so let's go add it!

I'll hold cmd and click to go into the ProgrammerRepository. Add the new method: public function findAllQueryBuilder(). For now, just return $this->createQueryBuilder(); with an alias of programmer:

... lines 1 - 8
class ProgrammerRepository extends EntityRepository
{
... lines 11 - 28
public function findAllQueryBuilder()
{
return $this->createQueryBuilder('programmer');
}
}

Perfect!

Creating the Pagerfanta Objects

This is all we need to use Pagerfanta. In the controller, start with $adapter = new DoctrineORMAdapter() - since we're using Doctrine - and pass it the query builder. Next, create a $pagerfanta variable set to new Pagerfanta() and pass it the adapter.

On the Pagerfanta object, call setMaxPerPage() and pass it 10. And then call $pagerfanta->setCurrentPage() and pass it $page:

... lines 1 - 10
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
... lines 13 - 20
class ProgrammerController extends BaseController
{
... lines 23 - 78
public function listAction(Request $request)
{
... lines 81 - 85
$adapter = new DoctrineORMAdapter($qb);
$pagerfanta = new Pagerfanta($adapter);
$pagerfanta->setMaxPerPage(10);
$pagerfanta->setCurrentPage($page);
... lines 90 - 102
}
... lines 104 - 202
}

Using Pagerfanta to Fetch Results

Ultimately, we need Pagerfanta to return the programmers that should be showing right now based on whatever page is being requested. To get that, use $pagerfanta->getCurrentPageResults(). But there's a problem: instead of returning an array of Programmer objects, this returns a type of traversable object with those programmes inside. This confuses the serializer. To fix that, create a new programmers array: $programmers = [].

Next, loop over that traversable object from Pagerfanta and push each Programmer object into our simple array. This gives us a clean array of Programmer objects:

... lines 1 - 20
class ProgrammerController extends BaseController
{
... lines 23 - 78
public function listAction(Request $request)
{
... lines 81 - 90
$programmers = [];
foreach ($pagerfanta->getCurrentPageResults() as $result) {
$programmers[] = $result;
}
... lines 95 - 102
}
... lines 104 - 202
}

And that means we're dangerous. In createApiResponse, we still need to pass in the programmers key, but we also need to add count and total. Add the total key and set it to $pagerfanta->getNbResults().

For count, that's easy: that's the current number of results that are shown on this page. Just use count($programmers):

... lines 1 - 20
class ProgrammerController extends BaseController
{
... lines 23 - 78
public function listAction(Request $request)
{
... lines 81 - 95
$response = $this->createApiResponse([
'total' => $pagerfanta->getNbResults(),
'count' => count($programmers),
'programmers' => $programmers,
], 200);
return $response;
}
... lines 104 - 202
}

We're definitely not done, but this should be enough to return a valid response on page 1 at least. Test it out. Copy the method name and use --filter to just run that test:

./bin/phpunit -c app --filter testGETProgrammersCollectionPaginated

This fails. But look closely: we do have programmers 0 through 9 in the response for page 1. It fails when trying to read the _links.next property because we haven't added those yet.

The PaginatedCollection

Before we add those, there's one improvement I want to make. Since we'll use pagination in a lot of places, we're going to need to duplicate this JSON structure. Why not create an object with these properties, and then let the serializer turn that object into JSON?

Create a new directory called Pagination. And inside of that, a new class to model this called PaginatedCollection. Make sure it's in the AppBundle\Pagination namespace. Very simply: give this 3 properties: items, total and count:

... lines 1 - 2
namespace AppBundle\Pagination;
class PaginatedCollection
{
private $items;
private $total;
private $count;
... lines 12 - 18
}

Generate the constructor and allow items and total to be passed. We don't need the count because again we can set it with $this->count = count($items). That should do it!

... lines 1 - 4
class PaginatedCollection
{
... lines 7 - 12
public function __construct(array $items, $totalItems)
{
$this->items = $items;
$this->total = $totalItems;
$this->count = count($items);
}
}

But something did just change: this object has an items property instead of programmers. That will change the JSON response. I made this change because I want to re-use this class for other resources. With a little serializer magic, you could make this dynamic: programmers in this case and something else like battles in other situations. But instead, I'm going to stay with items. This is something you often see with APIs: if they have their collection results under a key, they often use the same key - like items - for all responses.

But this means that I just changed our API. In the test, search for programmers: all of these keys need to change to items, so make sure you find them all:

... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 53
public function testGETProgrammersCollection()
{
... lines 56 - 66
$this->asserter()->assertResponsePropertyIsArray($response, 'items');
$this->asserter()->assertResponsePropertyCount($response, 'items', 2);
$this->asserter()->assertResponsePropertyEquals($response, 'items[1].nickname', 'CowboyCoder');
}
... line 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 83
$this->asserter()->assertResponsePropertyEquals(
$response,
'items[5].nickname',
'Programmer5'
);
... lines 89 - 97
$this->asserter()->assertResponsePropertyEquals(
$response,
'items[5].nickname',
'Programmer15'
);
... lines 103 - 107
$this->asserter()->assertResponsePropertyEquals(
$response,
'items[4].nickname',
'Programmer24'
);
$this->asserter()->assertResponsePropertyDoesNotExist($response, 'items[5].name');
... line 115
}
... lines 117 - 221
}

Using the new class is easy: $paginatedCollection = new PaginatedCollection(). Pass it $programmers and $pagerfanta->getNbResults().

To create the ApiResponse pass it the $paginatedCollection variable directly: $response = $this->createApiResponse($paginatedCollection):

... lines 1 - 10
use AppBundle\Pagination\PaginatedCollection;
... lines 12 - 21
class ProgrammerController extends BaseController
{
... lines 24 - 79
public function listAction(Request $request)
{
... lines 82 - 96
$paginatedCollection = new PaginatedCollection($programmers, $pagerfanta->getNbResults());
... lines 98 - 117
$response = $this->createApiResponse($paginatedCollection, 200);
return $response;
}
... lines 122 - 220
}

Try the test!

./bin/phpunit -c app --filter testGETProgrammersCollectionPaginated

It still fails, but only once it looks for the links. The first response looks exactly how we want it to. Okay, that's awesome - so now let's add some links.

Leave a comment!

6
Login or Register to join the conversation
Dirk Avatar

Is the total from NBresults and Count() not the same thing?

Reply

Hey Dirk

Actually no, count($programmers) it's the count of results shown in the page, and $pagerfanta->getNbResults() is the total number of results found in the database.

Cheers!

Reply
Default user avatar
Default user avatar Kreviouss | posted 5 years ago

I had to add "->orderBy('programmer.id', 'ASC')" in findAllQueryBuilder for this to work. I don't understand why it's always given me programmers in a random order.

Reply

Yo Kreviouss!

I agree - that's weird. But, we can easily debug and see what's going on. If you remove this orderBy line and refresh, you should be able to click the database icon in the web debug toolbar to go to the "Doctrine" tab on the profiler. On this page, you can see what the query looks like, to see if Doctrine is doing anything weird. It *should* simply be executing a query with *no* ORDER BY on it. If this is true, then it's actually your database that is returning things in a random order. In the profiler, you can click to get a "runnable" version of your query. If you run that directly on your database, you can see what happens: does it return in a random order, or ordered by id?

Let me know what you find out! Cheers!

Reply
Default user avatar

I fact, it's only on test mode. If i go to http://localhost:8000/api/programmers threw the navigator I have a normal request answer (from Programmer0 to Programmer9 with no ORDER BY). If I take the SQL and run it directly to my database, same good answer.
It's only when I run the test command that the answer is in a random order.
My normal and test database are build the same way.

I have tried to load fixtures in test database to see test environment in the navigator. And then, same answer as with the test command. A randomly ordered answer.

So it seems to be a test environment issue...

(sorry for my bad French English ><)

Reply

Huh, very interesting! Is your test database also in MySQL, or is it in Sqlite? I like how you debugged this: to load the fixtures in the test environment and then browse the site in the test environment. That's very puzzling :), as the same query should be used in both environments. Are you able to look at the test database to see if the data is loaded in the same way? I'm just trying to think of what could possibly be different between the test and normal databases...

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of REST and serialization are still valid, but I recommend using API Platform in new Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.3.3",
        "symfony/symfony": "2.6.*", // v2.6.11
        "doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
        "doctrine/dbal": "<2.5", // v2.4.4
        "doctrine/doctrine-bundle": "~1.2", // v1.4.0
        "twig/extensions": "~1.0", // v1.2.0
        "symfony/assetic-bundle": "~2.3", // v2.6.1
        "symfony/swiftmailer-bundle": "~2.3", // v2.3.8
        "symfony/monolog-bundle": "~2.4", // v2.7.1
        "sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
        "sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.0
        "hautelook/alice-bundle": "0.2.*", // 0.2
        "jms/serializer-bundle": "0.13.*", // 0.13.0
        "white-october/pagerfanta-bundle": "^1.0" // v1.2.4
    },
    "require-dev": {
        "sensio/generator-bundle": "~2.3", // v2.5.3
        "behat/behat": "~3.0", // v3.0.15
        "behat/mink-extension": "~2.0.1", // v2.0.1
        "behat/mink-goutte-driver": "~1.1.0", // v1.1.0
        "behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
        "phpunit/phpunit": "~4.6.0" // 4.6.4
    }
}
userVoice