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.
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.
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.
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!
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 | |
} |
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.
Hey mysiar,
I don't see we have any filters in the course code of this tutorial, are you talking about your private project? Well, in short, Pagerfanta works via GET, i.e. you can see query parameters in the URL. So, your filter form also should work via GET, not POST. Otherwise, you would need to send the form again on the 2nd page.
Cheers!
Hey @mysiar,
Cool! Then it should work I suppose - make sure the Pagerfanta navigation links hold those specific query parameters in their URLs and it just should work. IIRC Pagerfanta should hook those query parameters itself, i.e. it should build all navigation URLs based on the current URL you have in the browser's address bar.
Cheers!
Thanks! it works perfectly with me :)
I am wondering if it is possible to create button to Load More posts? Many thanks for your continued support!
Hey Lubna,
Perfect! Thanks for your feedback that it was useful to you :)
About the "Load more" feature - yes, you can implement it, but you would need to complicate your code with some JS, and Stimulus works great for this purpose (and we have a tutorial about Stimulus btw). But Stimulus isn't required and you can do it with a plain custom JS actually. You just need to send an AJAX request to the backend specifying the number of the page you're requesting, and instead of the whole rendered page render only the list of records that you will input on the page with the JS. And that's it, your paginator already knows how to return records for the specific page.
I hope this helps!
Cheers!
Soo, our old query anyways gets executed, but now as a subquery? And internally it grabs all the rows and then selects 5 ids from result of the subquery?
Hey Ruslan I.!
Yup, I believe so! I can't remember exactly why it's done this way. The pagination system needs to know both (A) which "5" results it should shows and also (B) how many total results there are. I believe (though it's not immediately obvious to me when I look at it) that the first query (with the sub-query) helps it know both which 5 ids it needs, but also how many total results there are (needed to show the correct number of pagination links).
I believe this strategy *can* cause performance issues with certain super-complex queries, but I've never hit this limitation myself.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"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.2.1
"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.17.5
"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/validator": "5.3.*", // v5.3.14
"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
}
}
I have a problem with pagination links and Symfony form.
When I submit my form I got filtered results but pagination links are just a tags and they do not submit the whole form with new page number
How to use pagerfanta with Symfony form ?