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 SubscribeWe... sort of have pagination working? Except that the number of pages and items per page are hard coded. Oh, and we're always returning every DailyStats
, no matter which page we're on. Ok, so it's not really working, but we're close.
There are two things that we need to know inside of DailyStatsPaginator
: we need to know what the current page is - which is normally in the URL - and the max results per page that we should be showing. If we had those two pieces of info, we could fill in all of the methods.
Since these are, sort of, "options" that control the behavior of our paginator, let's force them to be passed in via the constructor. Add int $currentPage
and int $maxResults
arguments to the constructor. I'll even add a comma to separate them!
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 14 | |
public function __construct(StatsHelper $statsHelper, int $currentPage, int $maxResults) | |
{ | |
... lines 17 - 19 | |
} | |
... lines 21 - 56 | |
} |
Next, hit Alt
+Enter
and go to "Initialize Properties" to create those two properties and set them:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 11 | |
private $currentPage; | |
private $maxResults; | |
public function __construct(StatsHelper $statsHelper, int $currentPage, int $maxResults) | |
{ | |
... line 17 | |
$this->currentPage = $currentPage; | |
$this->maxResults = $maxResults; | |
} | |
... lines 21 - 56 | |
} |
Sweet! Now in getCurrentPage()
, return $this->currentPage
and in getItemsPerPage()
, return $this->maxResults
:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 31 | |
public function getCurrentPage(): float | |
{ | |
return $this->currentPage; | |
} | |
public function getItemsPerPage(): float | |
{ | |
return $this->maxResults; | |
} | |
... lines 41 - 56 | |
} |
And... we even have enough info to complete the other methods. For getLastPage()
, I'll paste in a calculation:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 21 | |
public function getLastPage(): float | |
{ | |
return ceil($this->getTotalItems() / $this->getItemsPerPage()) ?: 1.; | |
} | |
... lines 26 - 61 | |
} |
This returns the ceiling of the total items divided by the max items per page. And if that equals zero because there are no results, return 1.
Next, in getTotalItems()
, return $this->statsHelper->count()
:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 26 | |
public function getTotalItems(): float | |
{ | |
return $this->statsHelper->count(); | |
} | |
... lines 31 - 61 | |
} |
That method returns the total number of results, not just the results on this page.
Finally, in getIterator()
, we need to figure out which results we should show based on the current page. We'll do that by calculating a limit and offset. Say $offset =
and I'll paste in another calculation:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 46 | |
public function getIterator() | |
{ | |
if ($this->dailyStatsIterator === null) { | |
$offset = (($this->getCurrentPage() - 1) * $this->getItemsPerPage()); | |
... lines 51 - 57 | |
} | |
... lines 59 - 60 | |
} | |
} |
The offset is the current page minus one, times the items per page.
Now, for fetchMany()
, this accepts limit and offset arguments. Pass it the limit - $this->getItemsPerPage()
- and then $offset
:
... lines 1 - 7 | |
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate | |
{ | |
... lines 10 - 46 | |
public function getIterator() | |
{ | |
if ($this->dailyStatsIterator === null) { | |
$offset = (($this->getCurrentPage() - 1) * $this->getItemsPerPage()); | |
$this->dailyStatsIterator = new \ArrayIterator( | |
$this->statsHelper->fetchMany( | |
$this->getItemsPerPage(), | |
$offset | |
) | |
); | |
} | |
... lines 59 - 60 | |
} | |
} |
Phew! Our paginator is now 100% ready. To test it, open DailyStatsProvider
. We now need to pass it the current page and the max items per page. To start, let's hardcode these: pretend we're on page 1 and we want to show 3 items per page so that pagination is really obvious:
... lines 1 - 12 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
... lines 15 - 21 | |
public function getCollection(string $resourceClass, string $operationName = null) | |
{ | |
return new DailyStatsPaginator( | |
$this->statsHelper, | |
1, | |
3 | |
); | |
} | |
... lines 30 - 39 | |
} |
Let's see what it look like! Refresh the page and... awesome! 3 results, 30 totalItems
- which is correct - and we're currently on page 1, next is page 2 and it will take us 10 pages to get through all 30 results. Our paginator is alive!
All we need to do now is remove these hard-coded values. So how do we figure out what the current page is... or the max items per page that we should be showing? We could just choose any number we want and put it here for the max items. And in a project, that would be fine.
But technically, the max items per page is something that is configurable via the ApiResource
annotation. And even the query parameter that's used for pagination can be changed - it doesn't need to be ?page=
.
My point is: we don't need to hardcode the max per page or read the page query parameter directly because API Platform already has this info! Where? It's hiding in a service called Pagination
: a service that we can autowire.
Add a second argument to DailyStatsProvider
: Pagination
- the one from DataProvider
and call it $pagination
:
... lines 1 - 6 | |
use ApiPlatform\Core\DataProvider\Pagination; | |
... lines 8 - 13 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
... lines 16 - 18 | |
public function __construct(StatsHelper $statsHelper, Pagination $pagination) | |
{ | |
... lines 21 - 22 | |
} | |
... lines 24 - 44 | |
} |
I'll hit Alt
+Enter
and go to Initialize Properties to create that property and set it:
... lines 1 - 13 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
... line 16 | |
private $pagination; | |
public function __construct(StatsHelper $statsHelper, Pagination $pagination) | |
{ | |
... line 21 | |
$this->pagination = $pagination; | |
} | |
... lines 24 - 44 | |
} |
Ok: Pagination
is an object that knows everything about the current pagination situation. So it has methods like getPage()
and getOffset()
, which is calculated from the current page and max items per page. We're going to use a - kind of strange - method called getPagination()
which returns 3 pieces of info as an array.
Check it out: use the odd function list()
to create three variables - $page
, $offset
and $limit
- and set this to $this->pagination->getPagination()
passing $resourceClass
and $operationName
:
... lines 1 - 13 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
... lines 16 - 24 | |
public function getCollection(string $resourceClass, string $operationName = null) | |
{ | |
list($page, $offset, $limit) = $this->pagination->getPagination($resourceClass, $operationName); | |
... lines 28 - 33 | |
} | |
... lines 35 - 44 | |
} |
Notice that there is a third argument - $context
. I'm not going to pass that simply because I don't have $context
in this method. But if you did want to support the full features of the pagination system - there are a few edge cases where pagination changes based on the context - then make your class implement ContextAwareCollectionDataProviderInterface
, which allows you to have a $context
argument on getCollection()
.
Anyways, hold Command
or Ctrl
to jump into the getPagination()
method. This returns an array containing the current page, the offset and the limit. We're using the strange list()
function as a quick way to create three new variables: $page
set to the array's zero index, $offset
set to the 1 index and $limit
to the 2 index.
Thanks to this, below, we can use $page
and $limit
:
... lines 1 - 13 | |
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface | |
{ | |
... lines 16 - 24 | |
public function getCollection(string $resourceClass, string $operationName = null) | |
{ | |
list($page, $offset, $limit) = $this->pagination->getPagination($resourceClass, $operationName); | |
return new DailyStatsPaginator( | |
$this->statsHelper, | |
$page, | |
$limit | |
); | |
} | |
... lines 35 - 44 | |
} |
We don't actually need $offset
because we're calculating that ourselves.
Let's see if it works! Move over, refresh and this time... hmm. It looks like it's not paginating at all. The problem is that API Platform allows 30 items per page by default. And our JSON file has... yep - 30 items in it.
Let's limit this to showing 7 results per page. To do that, go over to DailyStats
and pass a new option: paginationItemsPerPage
set to 7:
... lines 1 - 9 | |
/** | |
* @ApiResource( | |
... line 12 | |
* paginationItemsPerPage=7, | |
... lines 14 - 17 | |
* ) | |
*/ | |
class DailyStats | |
{ | |
... lines 22 - 53 | |
} |
This is why we read the max per page - which is the $limit
- from API Platform's Pagination
object instead of hardcoding it.
Now when we move over and try it... beautiful! Seven items and five pages total! Say hello to our home-rolled pagination!
Next: we have our get item and get collection operations working - even with pagination! Let's see if we can get the put
operation to work so that we can update DailyStats
data.
Hey Rafal,
Hm, interesting... in the latest version of ApiPlatform I see that $context is optional, see https://github.com/api-plat... . Probably it's required in your version, you can double-check that file in your vendor/ directory. But IIRC it should not be required anyway. It might be you misunderstand the error message?
Cheers!
Hey Victor,
Great tutorial - BTW :)
I'm using version 2.5.7. When you follow methods path you'll end up here: https://github.com/api-plat... - so page number is actually taken from the context.
Hey Rafal,
Thank you!
Ah, I think I see... You call getPagination() where the context is an optional arg, but then this method calls getPage() which in turn calls getParameterFromContext() where the $context is a required arg... though since $context arg has a default arg set to an empty array upstream - it should not throw any errors, but yeah, $context is data are required to work properly I think.
Cheers!
Hello ! I was wondering if it was possible to do something more generic for the Paginator. Currently, we need to create a Paginator class for each custom resource and implement its own logic for each.
If we changed this DailyStatsPaginator to a more generic class, which can be used for each custom resource, that would be great!
In this sense, I tried to modify the code of this class somewhat.
The actual result looks like this: https://gist.github.com/bas...
And now the current DailyStatsProvider class : https://gist.github.com/bas...
Currently, it works. But it is quite sensitive.
The best (I don't know if this has an impact on the performance side) would be to pass all the results each time (like with your KnpPaginatorBundle), and let the DataPaginator take care of the results to insert for the current page.
In this case, we are forced to pass as a parameter the results for the current page, as well as the total result count, while all this could be calculated by the DataPaginator, but in this case we would have to modify the behavior of the StatsHelper class .
Some improvements are therefore to be reviewed I think
Hey Kiuega
That's a good idea but also a bit hard to do. Want I recommend is to have at least 3 different cases where you want to reuse the paginator, so then, you can start looking for a way to parameterize all the things that varies and make the functionality (the paginator) more generic
The StatsHelper
class is just a convenient class to delegate some internal work, you should feel free to modify it as much as requiered for your application.
I don't know if it makes sense to you. Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^2.1", // v2.5.10
"composer/package-versions-deprecated": "^1.11", // 1.11.99
"doctrine/annotations": "^1.0", // 1.12.1
"doctrine/doctrine-bundle": "^2.0", // 2.1.2
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
"doctrine/orm": "^2.4.5", // 2.8.2
"nelmio/cors-bundle": "^2.1", // 2.1.0
"nesbot/carbon": "^2.17", // 2.39.1
"phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
"ramsey/uuid-doctrine": "^1.6", // 1.6.0
"symfony/asset": "5.1.*", // v5.1.5
"symfony/console": "5.1.*", // v5.1.5
"symfony/debug-bundle": "5.1.*", // v5.1.5
"symfony/dotenv": "5.1.*", // v5.1.5
"symfony/expression-language": "5.1.*", // v5.1.5
"symfony/flex": "^1.1", // v1.18.7
"symfony/framework-bundle": "5.1.*", // v5.1.5
"symfony/http-client": "5.1.*", // v5.1.5
"symfony/monolog-bundle": "^3.4", // v3.5.0
"symfony/security-bundle": "5.1.*", // v5.1.5
"symfony/twig-bundle": "5.1.*", // v5.1.5
"symfony/validator": "5.1.*", // v5.1.5
"symfony/webpack-encore-bundle": "^1.6", // v1.8.0
"symfony/yaml": "5.1.*" // v5.1.5
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
"symfony/browser-kit": "5.1.*", // v5.1.5
"symfony/css-selector": "5.1.*", // v5.1.5
"symfony/maker-bundle": "^1.11", // v1.23.0
"symfony/phpunit-bridge": "5.1.*", // v5.1.5
"symfony/stopwatch": "5.1.*", // v5.1.5
"symfony/twig-bundle": "5.1.*", // v5.1.5
"symfony/web-profiler-bundle": "5.1.*", // v5.1.5
"zenstruck/foundry": "^1.1" // v1.8.0
}
}
I'm not sure if this issue specific for my project, but without passing "$context" to "getPagination" I couldn't get actual page, so I did this way:
$this->pagination->getPagination($resourceClass, $operationName, $context);