Pagination Context

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We... 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
}

Completing all the Paginator Methods

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!

The Pagination Object

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.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.10
        "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.7.3
        "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.21.1
        "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.1.2
    }
}