Pagination on a Custom Resource
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 SubscribeWhen we fetch the collection of quests, we see all 50 of them! There's no pagination... a fact I can prove because, at the bottom we don't see any extra data about pagination.
Usually... if we peek at the treasures collection... at the bottom of the response, API Platform adds a hydra:view
field that describes how you can paginate through these resources. But over here for quests... zilch!
Pagination Comes from the Provider
But where does pagination come from in API Platform? It turns out that pagination is completely the responsibility of your state provider. It's... pretty simple actually. Whatever your collection provider returns - whether it's an array of quests... or some sort of iterable of quests - is what is serialized to JSON. But, if it returns an iterable object that happens to implement a special PaginatorInterface
, API Platform will see that and render the hydra:view
pagination details.
Using The TraversablePaginator
So, if we want our collection to support pagination, step one is, instead of returning this array, to return an object that implements that interface. And, fortunately, API Platform already has a class that can help us!
Set the array to a $quests
variable. Then return new TraversablePaginator
from API Platform. This takes a few arguments. First, a traversable - basically the results that should be shown for the current page. Right now, we'll still use all 50 quests. Oh, except this needs to be an iterable... so wrap it in a new ArrayIterator
.
Next is the current page - hardcode that to 1 for now - then items per page - hardcode that to 10 - and finally the total number of items, which for now, I'm just going to count $quests
.
// ... lines 1 - 6 | |
use ApiPlatform\State\Pagination\TraversablePaginator; | |
// ... lines 8 - 13 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 16 - 21 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
$quests = $this->createQuests(); | |
return new TraversablePaginator( | |
new \ArrayIterator($quests), | |
1, | |
10, | |
count($quests), | |
); | |
} | |
// ... lines 34 - 37 | |
} | |
// ... lines 39 - 64 | |
} |
This is not a very smart paginator yet: it will always be on page 1 and will show every result. But when we go over, refresh... and scroll to the bottom, we do see the pagination info! According to this, there are 5 pages of results... which makes sense: 10 items per page and 50 total items. But you'll also see that we're still returning 50 items. There's no real pagination happening!
Why? Because it's up to us to figure out which page we're on and to pass only the correct results to the paginator. If we pass it 50 items, it'll render 50 items, regardless of what we tell it are the max per page.
Organizing our Variables
To help us do that, let's set a few variables: $currentPage
hardcoded to 1, $itemsPerPage
hardcoded to 10 and $totalItems
. For this, call a new private method countTotalQuests()
.
// ... lines 1 - 13 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 16 - 21 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
$currentPage = 1; | |
$itemsPerPage = 10; | |
$totalItems = $this->countTotalQuests(); | |
// ... lines 28 - 36 | |
} | |
// ... lines 38 - 41 | |
} | |
// ... lines 43 - 74 | |
} |
I'll hit Alt+Enter and add that method at the bottom. This will return an int
... and I'm just going to return 50...
// ... lines 1 - 13 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 16 - 21 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
$currentPage = 1; | |
$itemsPerPage = 10; | |
$totalItems = $this->countTotalQuests(); | |
// ... lines 28 - 36 | |
} | |
// ... lines 38 - 41 | |
} | |
// ... lines 43 - 70 | |
private function countTotalQuests(): int | |
{ | |
return 50; | |
} | |
} |
because that's the total possible quests we have in our "fake" database. If you were using a database, you'd count every available row. Change the code in createQuests()
to use this.
This probably looks a bit silly: why am I creating a private method to return something so simple? Well, what I really want to highlight are the two distinct "jobs" of pagination. First, to return the correct subset of the 50 results - which we'll do in a moment. Second, to return the count of the total number of items. When you use Doctrine, it executes 2 separate queries for this: one to fetch the current page's results with a LIMIT and OFFSET, and a second COUNT query to count every row.
Current Page, Limit, Offset: The Pagination Service
Ok, back on top, let's use these variables: $currentPage
, $itemsPerPage
and $totalItems
.
// ... lines 1 - 13 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 16 - 21 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
$currentPage = 1; | |
$itemsPerPage = 10; | |
$totalItems = $this->countTotalQuests(); | |
// ... lines 28 - 30 | |
return new TraversablePaginator( | |
new \ArrayIterator($quests), | |
$currentPage, | |
$itemsPerPage, | |
$totalItems, | |
); | |
} | |
// ... lines 38 - 41 | |
} | |
// ... lines 43 - 70 | |
private function countTotalQuests(): int | |
{ | |
return 50; | |
} | |
} |
Ok cool... but what we really need to do is determine the actual current page and then use that to return only a subset of the results. Like, if we're showing 10 per page... and we're on page 2, we should return quests 11 through 20.
Pagination works via a ?page
query parameter: ?page=2
should mean we're on page 2. But our code isn't reading this yet. Look: it still thinks we're on page 1... because we've hardcoded that. To get the correct page, we could try to read the query parameter directly... but we don't need to! API Platform gives us a service that already holds all the pagination info.
On top, add a second constructor argument called private Pagination
- from API platform $pagination
.
// ... lines 1 - 6 | |
use ApiPlatform\State\Pagination\Pagination; | |
// ... lines 8 - 14 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
public function __construct( | |
private DragonTreasureRepository $treasureRepository, | |
private Pagination $pagination, | |
) | |
{ | |
} | |
// ... lines 23 - 78 | |
} |
Below, set $currentPage
to $this->pagination->getPage()
, which needs the $context
that we have as an argument on this method. Then $itemsPerPage
set to $this->pagination->getLimit()
passing $operation
and $context
. We can also get an $offset
in a similar way, which is super handy. If we're on page 2 and the limit is 10, the Pagination
service will calculate that the offset should be 11. Dump all four variables below.
// ... lines 1 - 6 | |
use ApiPlatform\State\Pagination\Pagination; | |
// ... lines 8 - 14 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
public function __construct( | |
private DragonTreasureRepository $treasureRepository, | |
private Pagination $pagination, | |
) | |
{ | |
} | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
$currentPage = $this->pagination->getPage($context); | |
$itemsPerPage = $this->pagination->getLimit($operation, $context); | |
$offset = $this->pagination->getOffset($operation, $context); | |
$totalItems = $this->countTotalQuests(); | |
dd($currentPage, $itemsPerPage, $offset, $totalItems); | |
// ... lines 32 - 40 | |
} | |
// ... lines 42 - 45 | |
} | |
// ... lines 47 - 78 | |
} |
Let's check this out! Go back to page 1, refresh and look at that! Page 1, 30 items per page, the limit and offset 0. If we go to page=2
, then it's page 2, the number per page is still 30 and the offset is 30.
Where is it getting 30 as the items per page? That's the default in API Platform for any resource. But this is something you can configure on your #[ApiResource]
attribute: change paginationItemsPerPage
to, how about, 10.
// ... lines 1 - 15 | |
( | |
// ... lines 17 - 23 | |
paginationItemsPerPage: 10, | |
// ... lines 25 - 26 | |
) | |
class DailyQuest | |
{ | |
// ... lines 30 - 49 | |
} |
Now try it. That changes to 10 and the offset is 10. If we go to page 3, our per page is still 10. And now it's saying:
Hey, since we're on page 3, you should start at result 20.
Fetching the Correct Results for the Current Page
We're in great shape now. Our final job is to use this info to return the correct subset of results, instead of all the quests. To do that, pass $offset
and $itemsPerPage
to createQuests()
.
// ... lines 1 - 14 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 17 - 23 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
// ... lines 27 - 29 | |
$totalItems = $this->countTotalQuests(); | |
$quests = $this->createQuests($offset, $itemsPerPage); | |
// ... lines 33 - 39 | |
} | |
// ... lines 41 - 44 | |
} | |
// ... lines 46 - 77 | |
} |
Down here, add int $offset
and int $limit
with a default of 50. And use those: $i = $offset
and then $i <=
$offset
plus $limit
.
// ... lines 1 - 14 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 17 - 23 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
if ($operation instanceof CollectionOperationInterface) { | |
// ... lines 27 - 29 | |
$totalItems = $this->countTotalQuests(); | |
$quests = $this->createQuests($offset, $itemsPerPage); | |
// ... lines 33 - 39 | |
} | |
// ... lines 41 - 44 | |
} | |
// ... line 46 | |
private function createQuests(int $offset, int $limit = 50): array | |
{ | |
// ... lines 49 - 52 | |
for ($i = $offset; $i < ($offset + $limit) && $i < $totalQuests; $i++) { | |
// ... lines 54 - 68 | |
} | |
// ... lines 70 - 71 | |
} | |
// ... lines 73 - 77 | |
} |
Ok team check it out! We're on page 3 and... these are the items from page 3! It's more obvious if we go to page 1. See the descriptions: description 1, 2, 3 and so on. So, pagination is working on our collection!
Though, in this simple example, I need to make sure I don't break the item provider. Because we're looking up the day string as an array key, we need to return all the quests. To make sure that happens, pass 0 and 50.
// ... lines 1 - 14 | |
class DailyQuestStateProvider implements ProviderInterface | |
{ | |
// ... lines 17 - 23 | |
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
{ | |
// ... lines 26 - 41 | |
$quests = $this->createQuests(0, $this->countTotalQuests()); | |
// ... lines 43 - 44 | |
} | |
// ... lines 46 - 77 | |
} |
In a real app, you would make this smarter by, for example, querying for the one item you need... instead of loading all of them.
So that's pagination for a custom resource. What about filtering? We're going to talk about creating custom filters in a future tutorial. But spoiler alert: the filtering logic is also something that happens right here inside the collection provider.
Next: let's remove all the API resource stuff from our User
entity and add it to a new class that's going to be dedicated to our API. Woh.
Even if we go to page=100, it shows up the list of 10 items, it should be limit to 50 as max , no ?
http://127.0.0.1:8000/api/quests.jsonld?page=100