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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeI 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:^3.6"
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.
So I don't know if I have an issue that I'm just not understanding how to make work properly or one that needs a different adapter type. I have a query that joins 3 different tables and one I need to write a raw query for so it doesn't seem to return the QueryBuilder type that the adapter is expecting. In a case like this should I just return an array with all the results (we're not talking hundreds here but enough to page) and try to use an Array Adapter?
Thanks and Happy Holidays!!