Pagination
In this video we are going to add in the ability to Paginate the result sets that we retrieve when searching for wallpapers in a given category.
For complete clarity, as our site grows a category may come to contain 100's of images. As it stands currently we aren't doing any image processing, so when a visitor to our site browses any particular page, each image they see is a full size image. We're currently just shrinking that image down to fit the given container.
If each image is 1mb in size, imagine how slowly our page would load if we had 100 images to display. Horrendous.
But of course, we won't be returning the full size image to our site visitors when we're done. But even so, studies have shown that breaking up result sets such as this into smaller chunks is the preferred way to consume this type of content.
Anyway, learning how to paginate data is a useful skill to have. And fortunately, there's a Bundle for that!
Pagination in Symfony with KnpPaginatorBundle
Because pagination - whether in a Symfony environment or not - is such a common occurrence, it's unsurprising that there is a pre-existing Symfony bundle that adds in pagination functionality in a few short steps.
Maybe you've heard about Symfony's Bundles but aren't quite sure what they are. Think about them as "plugins" to your application. In much the same way that WordPress has its huge selection of plugins to meet most any need, so to does Symfony with the bundle concept.
The really nice part about using these third party bundles is that with very little additional effort on our part, we gain a huge amount of functionality. Often these bundles have been used by hundreds or thousands of other developers on other projects. This usually leads to a much more robust and reliable codebase than we could create given existing time pressures to get the wider project completed.
It is worth mentioning that a bundle is not "free". Yes, they are free in terms of being open source software that you do not need to directly exchange money to use. But they add an extra dependency to your project and you will be responsible for managing that dependency. As ever, it's a trade off.
Taking all this into consideration, at the time of writing / recording, KnpPaginatorBundle is at version 2.5.4.
It has had 363 commits from 103 contributors.
According to packagist, KnpPaginatorBundle has been installed 4,433,452 million times.
Given these facts, do I think I could write a better pagination implementation in one video as part of a wider course? The answer is hopefully obvious: no, of course I couldn't.
So that'll be KnpPaginatorBundle has been installed 4,433,453 million times then :)
Installing KnpPaginatorBundle
There are pre-existing installation instructions available in the official documentation for KnpPaginatorBundle.
For reference, add the bundle to your project using composer:
composer require knplabs/knp-paginator-bundle
When the bundle has downloaded, register the bundle entry in AppKernel.php
:
// /app/AppKernel.php
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = [
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\SecurityBundle\SecurityBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Symfony\Bundle\MonologBundle\MonologBundle(),
new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(),
new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(),
new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(),
new Knp\Bundle\PaginatorBundle\KnpPaginatorBundle(),
new AppBundle\AppBundle(),
];
// * snip *
I always follow this pattern. First I have all the bundles that were originally added when installing Symfony. This goes from FrameworkBundle
down to SensioFrameworkExtraBundle
.
I then put a new line or two, and add in any third party bundles - such as KnpPaginatorBundle
. If I had other bundles added to the project, I would add them here in this second 'chunk', and for my own sanity, I like to sort them alphabetically also.
Lastly, I add in a third 'chunk' which typically these days only ever contains AppBundle
. In the earlier days of Symfony 2.x, we would typically create many individual bundles in our own projects. This is no longer considered a good practice, and having made the change myself, I completely agree with this direction. If you are new to Symfony, you can ignore this entirely and consider it merely historical Symfony trivia.
Finally, we need to add some configuration to config.yml
to ensure that KnpPaginatorBundle
behaves itself:
# /app/config/config.yml
framework:
#esi: ~
translator: { fallbacks: ['%locale%'] } # uncomment this line
First up, the sneaky one. Make sure to uncomment the translator
configuration entry. This simply means removing the hash (#
) from the start of the line.
This one is not immediately documented on the KnpPaginatorBundle website.
What happens if you don't uncomment this? Well, when adding in the pagination display templates you will see label_previous
instead of 'Previous', and label_next
instead of 'Next'. It's a simple fix, and we've made it preemptively anyway.
Next, we need to add in some knp_paginator
specific configuration. This is documented in the official docs. I'm going to start by copying everything over from their documentation. However, as we're using Bootstrap for styling, I'm going to change up the pagination
template to use the Bootstrap-styled pagination component:
# /app/config/config.yml
knp_paginator:
page_range: 5 # default page range used in pagination control
default_options:
page_name: page # page query parameter name
sort_field_name: sort # sort field query parameter name
sort_direction_name: direction # sort direction query parameter name
distinct: true # ensure distinct results, useful when ORM queries are using GROUP BY statements
template:
pagination: 'KnpPaginatorBundle:Pagination:twitter_bootstrap_v3_pagination.html.twig' # sliding pagination controls template
sortable: 'KnpPaginatorBundle:Pagination:sortable_link.html.twig' # sort link template
We don't actually need all this configuration though. We're accepting the defaults for everything except the template.pagination
option. As a result, we could reduce our config down significantly:
# /app/config/config.yml
knp_paginator:
template:
pagination: 'KnpPaginatorBundle:Pagination:twitter_bootstrap_v3_pagination.html.twig' # sliding pagination controls template
But you may be asking yourself - if we don't supply these options, where do these default options come from? Though not super intuitive, the defaults come as part of the bundle's configuration. This is a more advanced concept and not something you need to concern yourself with. But it's nice to know.
These extra options are not immediately useful to us anyway. We won't be concerning ourselves with sorting or filtering just yet.
Speaking of code, let's start writing some!
Using KnpPaginatorBundle In Our Code
The documentation lists a bunch of 'things' that the paginator can paginate over.
As we don't yet have a database-backed system, we are currently working with an array
of data. Fortunately, KnpPaginatorBundle can work with arrays. Phew.
Now, there isn't an immediately available example on the official documentation as to how to use an array with the paginator. The example given is for paginating a Doctrine query. Don't be alarmed, working with an array is easier than the given example:
<?php
/src/AppBundle/Controller/DefaultController.php
namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
class GalleryController extends Controller
{
/**
* @Route("/", name="gallery")
*/
public function indexAction(Request $request)
{
$images = [
'landscape-summer-beach.jpg',
'landscape-summer-field.jpg',
'landscape-summer-flowers.jpg',
'landscape-summer-hill.jpg',
'landscape-summer-mountain.png',
'landscape-summer-sea.jpg',
'landscape-summer-sky.jpg',
];
$paginator = $this->get('knp_paginator');
$pagination = $paginator->paginate(
$images,
$request->query->getInt('page', 1) /*current page number*/,
4 /*images per page*/
);
return $this->render('gallery/index.html.twig', [
'images' => $pagination
]);
}
}
Whilst in the previous video we worked with a single image which we duplicated multiple times to create our array entries, in this video I'm going to make things less repetitive by adding in a few different images. Conceptually however, the process is identical. In other words, so long as files with the given names exist in the /web/images
directory we are good to go.
With an array of data, we now have something interesting we can paginate over.
$paginator = $this->get('knp_paginator');
We've added KnpPaginatorBundle as a dependency of our project. We've enabled it, and we've added a bit of config to make sure that when used, it behaves in-line with our expectations.
Now, we need to actually use it.
But what is 'it'?
Well, like most everything in our codebase, the paginator is an object.
We could new
up an instance of the paginator ourselves which would involve making sure we provide all the right configuration options and then, ideally, extract this setup logic out so that if we want to use the paginator in other classes, we can call that shared location so we don't keep duplicating paginator setup everywhere.
But we don't need to do this.
At a high level, this is what the bundle is doing for us.
By adding the bundle and registering it with our Symfony application (via the entry in AppKernel.php
from earlier), all of this hard work has been done for us. The paginator is available to us as a pre-configured Symfony service. All we need to do is get hold of that service, and start using it.
Whilst the documentation for any bundle should tell us exactly this kind of thing, if you are ever unsure what the service might be called, you can do a little code (or, in this case, configuration) diving and find out for yourself. Typically this file should live inside the bundle's Resources/config
directory. The file name is not always the same, but there are rarely that many of them, so poking around in each isn't that difficult.
A quick glance inside the Resources/config/paginator.xml
file reveals a bundle of service
entries with id
's starting with knp_paginator
:
<!-- /vendor/knplabs/knp-paginator-bundle/Resources/config/paginator.xml -->
<parameters>
<parameter key="knp_paginator.class">Knp\Component\Pager\Paginator</parameter>
<parameter key="knp_paginator.helper.processor.class">Knp\Bundle\PaginatorBundle\Helper\Processor</parameter>
</parameters>
<services>
<service id="knp_paginator" class="%knp_paginator.class%">
<argument type="service" id="event_dispatcher" />
</service>
Knowing this, we can run a Symfony debug
command against the container
to find out more:
php bin/console debug:container knp_paginator
Information for Service "knp_paginator"
=======================================
------------------ -------------------------------
Option Value
------------------ -------------------------------
Service ID knp_paginator
Class Knp\Component\Pager\Paginator
Tags -
Calls setDefaultPaginatorOptions
Public yes
Synthetic no
Lazy no
Shared yes
Abstract no
Autowired no
Autowiring Types -
------------------ -------------------------------
To wrap up here, by calling get
inside our GalleryController::indexAction
, we are asking Symfony to look inside the container and find the service that has the id
of knp_paginator
:
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class GalleryController extends Controller
{
/**
* @Route("/", name="gallery")
*/
public function indexAction(Request $request)
{
$images = [...];
$paginator = $this->get('knp_paginator');
get
is available to us because we extends Controller
, with Controller
being Symfony\Bundle\FrameworkBundle\Controller\Controller
.
If you use an IDE like PhpStorm, you can ctrl / cmd click the get
method after which you should see:
/**
* Gets a container service by its id.
*
* @param string $id The service id
*
* @return object The service
*/
protected function get($id)
{
return $this->container->get($id);
}
Ok, so again, you don't need to know this stuff to use the paginator. In my opinion, it's just nice to demystify what the heck is happening behind the scenes, and to see where all this stuff is coming from.
Want to know more? Check out the official Symfony docs on the Service Container.
Paginating
We've gained access to the pagination service and assigned that to a variable named $paginator
.
From that service we now want to call the paginate
method.
As arguments to this method we need to provide:
- The data to paginate over
- The current 'page'
- How many items to display per 'page'
To clarify, imagine we have a set of data with ten entries in it.
We want to split this into groups (or 'pages') of four.
10 / 4 doesn't quite go.
The paginator will handle this for us, splitting into chunks / groups / pages of 4, and then however many are left over on the last page.
In other words, we will get three pages.
The first page will have four items.
The second page will have the next four items.
The third and last page will have the last two items.
How would we write this in code?
$pagination = $paginator->paginate(
$images, /* array of images */
$request->query->getInt('page', 1), /*current page number*/
4 /*images per page*/
);
This first and third argument are fairly self explanatory.
The second argument requires a touch more explanation.
We need a way to figure out which 'page' we are currently on. To do this, we can examine the current URI.
When we first load up the gallery page, our URI will look like this:
http://127.0.0.1:8000/gallery
Assuming we have 10 images in our result set ($images
), then we should expect the paginator to have split the data into 3 pages.
We haven't yet added in the Twig template to allow this, but ignore this for the moment and think of this as the 'theory' portion.
To get the second page, we need a way to tell the controller that hey, we now want page 2.
We could do this by browsing to:
http://127.0.0.1:8000/gallery?page=2
The exact same controller action will be called. Only, this time when the page
query parameter is inspected, the value of page
will be 2:
$request->query->getInt('page', 1)
In other words, from the current request, get me the integer value for the query parameter for page
. If page
is not set, default to 1.
Because of this default behaviour, that's why we don't need to initially browse to:
http://127.0.0.1:8000/gallery?page=1
But here's if we use the paginator component and browse back to page 1, then you will see the above URL is used.
If you'd like to know more about using the URL in this way, I have a free video that covers this topic in further depth.
One last thing, if you don't like the word page
, then you can change it in the configuration we covered earlier:
# /app/config/config.yml
knp_paginator:
default_options:
page_name: chunk # page query parameter name
If you do this, be sure to update the code accordingly:
$pagination = $paginator->paginate(
$images, /* array of images */
$request->query->getInt('chunk', 1), /*current page number*/
4 /*images per page*/
);
Let's See This In Action
Let's very quickly recap our controller action:
<?php
/src/AppBundle/Controller/DefaultController.php
namespace AppBundle\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
class GalleryController extends Controller
{
/**
* @Route("/", name="gallery")
*/
public function indexAction(Request $request)
{
$images = [
'landscape-summer-beach.jpg',
'landscape-summer-field.jpg',
'landscape-summer-flowers.jpg',
'landscape-summer-hill.jpg',
'landscape-summer-mountain.png',
'landscape-summer-sea.jpg',
'landscape-summer-sky.jpg',
];
$paginator = $this->get('knp_paginator');
$pagination = $paginator->paginate(
$images,
$request->query->getInt('page', 1) /*current page number*/,
4 /*images per page*/
);
return $this->render('gallery/index.html.twig', [
'images' => $pagination
]);
}
}
Ok, so we have some data, it's been through the paginator, and now we need to show it to the site visitor:
return $this->render('gallery/index.html.twig', [
'image' => $images
]);
We already have this Twig template:
{% extends 'base.html.twig' %}
{% block body %}
<div class="row">
{% for image in images %}
<div class="col-sm-6 col-md-4">
<div class="thumbnail">
<img src="images/{{ image }}" alt="some text here">
<div class="caption">
<h3>Thumbnail label</h3>
<p>...</p>
<p><a href="#" class="btn btn-primary" role="button">Button</a> <a href="#" class="btn btn-default" role="button">Button</a></p>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
All of this still stands. We just need to call the pagination helper function, and pass in the images:
{% extends 'base.html.twig' %}
{% block body %}
<div class="row">
{% for image in images %}
<div class="col-sm-12 col-md-6">
<div class="thumbnail">
<img src="images/{{ image }}" alt="some text here">
<div class="caption">
<h3>Thumbnail label</h3>
<p>...</p>
<p><a href="#" class="btn btn-primary" role="button">Button</a> <a href="#" class="btn btn-default" role="button">Button</a></p>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="navigation text-center">
{{ knp_pagination_render(images) }}
</div>
{% endblock %}
Again, to be absolutely clear, knp_pagination_render
is a Twig extension function made available to us because we are using the KnpPaginatorBundle.
Earlier we swapped out the default pagination template for the Bootstrap-styled one:
# /app/config/config.yml
knp_paginator:
template:
pagination: 'KnpPaginatorBundle:Pagination:twitter_bootstrap_v3_pagination.html.twig' # sliding pagination controls template
It's at this point that this particular piece of configuration is actually used.
Anyway, there we go. We have implemented pagination over our images. Even though we have an array of images at this point, by using the KnpPaginatorBundle we have ensured that we won't need to do anything more drastic than change up the data we provide (a query instead of an array) to continue using this pagination setup once we get the database involved.