Pagination with Pagerfanta

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I want to add one more Doctrine-specific feature to our site: pagination.

Right now, on the homepage, we're rendering every question on the site. That's... not very realistic. Instead, let's render 5 on each page with pagination links.

KnpPaginator and Pagerfanta

Doctrine does come with tools for pagination... but they're a little "low level". Fortunately, the Symfony ecosystem has two libraries that build on top of Doctrine's tools to make pagination a pleasure. They're called KnpPaginator and Pagerfanta.

Both of these are really good... and I have a hard time choosing between them. In our Symfony 4 Doctrine tutorial, we talked about KnpPaginator. So in this tutorial, let's explore Pagerfanta.

Installing PagerfantaBundle

Search for "pagerfanta bundle" to find a GitHub page under the "BabDev" organization. Scroll down a little and click into the documentation.

The PagerfantaBundle is a wrapper around a Pagerfanta library that holds most of the functionality. So the documentation is kind of split between the bundle and the library. Open the docs for the library in another tab so we have it handy... then come back and click "Installation".

Copy the "composer require" line, spin over to your terminal and get it:

composer require babdev/pagerfanta-bundle

Let's see what that did:

git status

Ok: nothing too interesting... though it did automatically enable the new bundle.

Pagers Work with QueryBuilders

The controller for the homepage lives at src/Controller/QuestionController.php: the homepage action.

... lines 1 - 15
class QuestionController extends AbstractController
{
... lines 18 - 27
/**
* @Route("/", name="app_homepage")
*/
public function homepage(QuestionRepository $repository)
{
$questions = $repository->findAllAskedOrderedByNewest();
return $this->render('question/homepage.html.twig', [
'questions' => $questions,
]);
}
... lines 39 - 80
}

We're calling this custom repository method, which returns an array of Question objects.

... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 22
/**
* @return Question[] Returns an array of Question objects
*/
public function findAllAskedOrderedByNewest()
{
... lines 28 - 35
}
... lines 37 - 59
}

The biggest difference when using a paginator is that we will no longer execute the query directly. Instead, our job will be to create a QueryBuilder and pass that to the paginator... which will then figure out which page we're on, set up the limit and offset parts of the query, and then execute it.

In other words, to prep for Pagerfanta, instead of returning an array of Question objects, we need to return a QueryBuilder. Rename the method to createAskedOrderedByNewestQueryBuilder() - good luck thinking of a longer name than that - and it will return a QueryBuilder.

... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 21
public function createAskedOrderedByNewestQueryBuilder(): QueryBuilder
{
... lines 25 - 30
}
... lines 32 - 54
}

Inside, all we need to do is remove getQuery() and getResult().

... lines 1 - 15
class QuestionRepository extends ServiceEntityRepository
{
... lines 18 - 21
public function createAskedOrderedByNewestQueryBuilder(): QueryBuilder
{
return $this->addIsAskedQueryBuilder()
->orderBy('q.askedAt', 'DESC')
->leftJoin('q.questionTags', 'question_tag')
->innerJoin('question_tag.tag', 'tag')
->addSelect('question_tag', 'tag')
;
}
... lines 32 - 54
}

Back over in the controller, change this to $queryBuilder equals $repository->createAskedOrderedByNewestQueryBuilder().

... lines 1 - 15
class QuestionController extends AbstractController
{
... lines 18 - 30
public function homepage(QuestionRepository $repository)
{
$queryBuilder = $repository->createAskedOrderedByNewestQueryBuilder();
... lines 34 - 37
}
... lines 39 - 80
}

We're ready!

Installing the ORM Pagerfanta Adapter

The next step is to create a Pagerfanta object... you can see how in the "Rendering Pagerfantas" section. This looks simple enough: create a new Pagerfanta and, because we're using Doctrine, create a new QueryAdapter and pass in our $queryBuilder.

Cool: $pagerfanta = new Pagerfanta()... and new QueryAdapter()... huh. PhpStorm isn't finding that class!

This is a... kind of weird... but also really cool thing about the Pagerfanta packages. Go back to library's documentation and click "Pagination Adapters". The Pagerfanta library can be used to paginate a lot of different things. Actually, click "Available Adapters".

For example, you can use Pagerfanta to paginate a relationship property - like $question->getAnswers() - via its CollectionAdapter. Or you can use it to paginate Doctrine DBAL queries... which is a lower-level way to use Doctrine. You can also paginate MongoDB or, if you're using the Doctrine ORM like we are, you can paginate with the QueryAdapter.

This is cool! But each adapter lives in its own Composer package... which is why we don't have the QueryAdapter class yet. So let's install it: copy the package name, spin over to your terminal, and run:

composer require pagerfanta/doctrine-orm-adapter

Once PhpStorm indexes the new code... try new QueryAdapter() again. We have it! Pass this $queryBuilder. We can also configure a few things, like ->setMaxPerPage(5). I'm using 5 per page so that pagination is really obvious.

... lines 1 - 9
use Pagerfanta\Doctrine\ORM\QueryAdapter;
use Pagerfanta\Pagerfanta;
... lines 12 - 17
class QuestionController extends AbstractController
{
... lines 20 - 32
public function homepage(QuestionRepository $repository)
{
$queryBuilder = $repository->createAskedOrderedByNewestQueryBuilder();
$pagerfanta = new Pagerfanta(new QueryAdapter($queryBuilder));
$pagerfanta->setMaxPerPage(5);
... lines 39 - 42
}
... lines 44 - 85
}

Looping Over a Pagerfanta

For the template, instead of passing a questions variable, we're going to pass a pager variable set to the $pagerfanta object.

... lines 1 - 17
class QuestionController extends AbstractController
{
... lines 20 - 32
public function homepage(QuestionRepository $repository)
{
$queryBuilder = $repository->createAskedOrderedByNewestQueryBuilder();
$pagerfanta = new Pagerfanta(new QueryAdapter($queryBuilder));
$pagerfanta->setMaxPerPage(5);
return $this->render('question/homepage.html.twig', [
'pager' => $pagerfanta,
]);
}
... lines 44 - 85
}

Now, pop into the homepage template... and scroll up. We were looping over the questions array.

... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in questions %}
... lines 18 - 47
{% endfor %}
</div>
</div>
... lines 51 - 53

What do we do now? Loop over pager: for question in pager.

... lines 1 - 9
<div class="container">
... lines 11 - 15
<div class="row">
{% for question in pager %}
... lines 18 - 47
{% endfor %}
</div>
</div>
... lines 51 - 53

Yup, we can treat the Pagerfanta object like an array. The moment that we loop, Pagerfanta will execute the query it needs to get the results for the current page.

Testing time! Go back to the homepage. If we refresh now... 1, 2, 3, 4, 5. Yes! The paginator is limiting the results!

And check out the query for this page. Remember, the original query - before we added pagination - was already pretty complex. The pager wrapped that query in another query to get just the 5 question ids needed, ordered in the right way. Then, with a second query, it grabbed the data for those 5 questions.

The point is: the pager does some heavy lifting to make this work... and our complex query doesn't cause any issues.

So... cool! It returned only the first 5 results! But what about pagination links? Like a link to get to the next page... or the last page? Let's handle that next.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.3
        "doctrine/doctrine-bundle": "^2.1", // 2.4.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.9.5
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "sensio/framework-extra-bundle": "^6.0", // v6.1.5
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.7
        "symfony/flex": "^1.3.1", // v1.15.1
        "symfony/framework-bundle": "5.3.*", // v5.3.7
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.33.0
        "symfony/var-dumper": "5.3.*", // v5.3.7
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.5
        "zenstruck/foundry": "^1.1" // v1.13.1
    }
}