Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Paginator

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

When an API resource is a Doctrine entity like CheeseListing, life is good! We get tons of things for free, like pagination, access to a bunch of pre-built filters and one time I swear I got free pizza.

But... these filters will not work for custom resources... because API platform has no idea how to work with your custom data source. Nope, if you want filters or pagination on a custom API resource, you need to some work yourself.

But no worries: it's not too hard and we're kicking butt anyways. Let's start with pagination.

Pagination and your Data Provider

Right now, our collection endpoint lists every daily stats items... no matter how many there are.

Here's how pagination works: inside our data provider, instead of returning an array of DailyStats objects from getCollection(), we're going to return a Paginator object that contains the DailyStats objects that should be returned for whatever page we're on:

... lines 1 - 12
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 21
public function getCollection(string $resourceClass, string $operationName = null)
{
return $this->statsHelper->fetchMany();
}
... lines 26 - 35
}

In the src/DataProvider/ directory, create a new PHP class called DailyStatsPaginator. The only rule is that this needs to implement a PaginatorInterface from ApiPlatform:

... lines 1 - 2
namespace App\DataProvider;
use ApiPlatform\Core\DataProvider\PaginatorInterface;
class DailyStatsPaginator implements PaginatorInterface
{
... lines 9 - 27
}

I'll go to "Code"->"Generate" - or Command+N on a Mac - and select "Implement Methods". Select all five methods we need.

Perfect! Oh, but I'll move count() to the bottom... it just feels better for me down there:

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface
{
public function getLastPage(): float
{
}
public function getTotalItems(): float
{
}
public function getCurrentPage(): float
{
}
public function getItemsPerPage(): float
{
}
public function count()
{
}
}

To get this working, let's return some dummy data! In getLastPage(), pretend that there are two pages, for getTotalItems(), pretend there are 25 items, for getCurrentPage(), pretend we're on page 1 and for getItemsPerPage() return that we want to show 10 items per page. For count() return $this->getTotalItems():

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface
{
public function getLastPage(): float
{
return 2;
}
public function getTotalItems(): float
{
return 25;
}
public function getCurrentPage(): float
{
return 1;
}
public function getItemsPerPage(): float
{
return 10;
}
public function count()
{
return $this->getTotalItems();
}
}

Oh, yea know what? When I recorded this, I made a careless mistake. The count() method should actually return the number of items on this page - not the total number of items. I'll mention that in a few minutes when we actually have items to count.

Ok: there are no DailyStats objects inside this class yet... but let's do as little as possible to see if we can get things working.

Back in the provider, instead of returning the array of objects, return a new DailyStatsPaginator:

... lines 1 - 12
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 21
public function getCollection(string $resourceClass, string $operationName = null)
{
return new DailyStatsPaginator();
}
... lines 26 - 35
}

Ooh. Let's try it. Head back over and refresh the collection endpoint. And... error!

Class DailyStatsPaginator must implement the interface Traversable as part of either Iterator or IteratorAggregate.

Wow. Okay. So the PaginatorInterface gives API Platform a bunch of info about pagination, like how many pages there are and what page we're currently on. But ultimately, whatever we return from getCollection() needs to be something that API platform can loop over - can iterate over.

In other words, we need to make our paginator iterable. One way to do that is to add a second interface: \IteratorAggregate:

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 9 - 44
}

And then, I'm going to create a new private property called $dailyStatsIterator:

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
private $dailyStatsIterator;
... lines 10 - 44
}

Finally, thanks to the new interface, at the bottom, go to "Code"->"Generate" - or Command+N on a Mac - and select "Implement Methods". This requires one new function called getIterator():

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 9 - 35
public function getIterator()
{
... lines 38 - 43
}
}

Inside, first check to see if $this->dailyStatsIterator === null. If it is null, set it: $this->dailyStatsIterator = and use another core class new \ArrayIterator(). For now, pass this an empty array. I'll put a little TODO up here:

todo - actually go "load" the stats

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 9 - 35
public function getIterator()
{
if ($this->dailyStatsIterator === null) {
// todo - actually go "load" the stats
$this->dailyStatsIterator = new \ArrayIterator([]);
}
... lines 42 - 43
}
}

At the bottom of the method, return $this->dailyStatsIterator:

... lines 1 - 6
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 9 - 35
public function getIterator()
{
if ($this->dailyStatsIterator === null) {
// todo - actually go "load" the stats
$this->dailyStatsIterator = new \ArrayIterator([]);
}
return $this->dailyStatsIterator;
}
}

Basically, we're creating an iterator with the DailyStats objects inside. Well, it's an empty array now, but it will hold DailyStats soon. The property and if statement are there in case getIterator() is called multiple times. If it is, this prevents us from unnecessarily creating the iterator again.

Anyways, thanks to this, our paginator class is now iterable! And when we refresh the collection endpoint... it works! I mean, there's nothing inside the collection - hydra:member is empty - but you can see totalItems 25 and links below to tell us how to get to the first, next and last pages.

Iterating over DailyStats in the Paginator

To do the heavy lifting of loading the DailyStats objects, we can leverage the StatsHelper object, which we have access to inside of DailyStatsProvider.

Inside DailyStatsPaginator, add a constructor: public function __construct() with StatsHelper $statsHelper:

... lines 1 - 5
use App\Service\StatsHelper;
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 10 - 12
public function __construct(StatsHelper $statsHelper)
{
... line 15
}
... lines 17 - 52
}

Now, this class is not a service... So Symfony is not going to autowire this argument. This is really a "model" class that represents a paginated collection of DailyStats. But in a minute, we'll pass StatsHelper directly when we create this object.

Anyways, hit Alt+Enter and go to "Initialize properties" to create that property and set it:

... lines 1 - 7
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... line 10
private $statsHelper;
public function __construct(StatsHelper $statsHelper)
{
$this->statsHelper = $statsHelper;
}
... lines 17 - 52
}

Then, before we use it, go to DailyStatsProvider and pass $this->statsHelper when we instantiate DailyStatsPaginator:

... 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);
}
... lines 26 - 35
}

For now, we're going to completely ignore pagination and still show all of the DailyStats no matter which page we're on. To do that, down in getIterator(), instead of the empty array, pass $this->statsHelper->fetchMany():

... lines 1 - 7
class DailyStatsPaginator implements PaginatorInterface, \IteratorAggregate
{
... lines 10 - 42
public function getIterator()
{
if ($this->dailyStatsIterator === null) {
$this->dailyStatsIterator = new \ArrayIterator(
$this->statsHelper->fetchMany()
);
}
... lines 50 - 51
}
}

Oh, and by the way, you could now update the count() method to leverage getIterator(): return iterator_count($this->getIterator()):

public function count()
{
    return iterator_count($this->getIterator());
}

Anyways, let's check it out! Head over to your browser, refresh and... it works! Well, hydra:member still contains every DailyStats record... but all the pagination info is there.

So next, let's finish this! To do that, we'll need to ask API Platform:

Hey! What page are we on?

And also:

Yo! How many items per page should I show?

Because technically, that's configurable via annotations.

Leave a comment!

5
Login or Register to join the conversation
Denrolya Avatar
Denrolya Avatar Denrolya | posted 1 year ago | edited

Hey there !
Is it possible to extend Paginator somehow with custom properties? I want it to include not only hydra:member & hydra:totalItems but a custom property. Imagine querying transactions - what I want is to receive 20 transactions per page and calculate the total value of all transactions that match the query
Cheers!

Reply

Hey Denrolya!

That's an interesting question! I've never done it before, but I believe you need a custom normalizer. When you have a paginated response, what really happens (assuming you're using Doctrine ORM) is that this class - https://github.com/api-plat... - is your "data" and then that is run through the serializer. Other normalizers - for example https://github.com/api-plat... - already look for this class (well, specifically, they usually look for PaginatorInterface) and add the properties you see. So, if you create a custom normalizer and target the Paginator class, you should be able to add new properties :).

Cheers!

Reply
Raul M. Avatar
Raul M. Avatar Raul M. | posted 1 year ago

Hi there,

I am implementing a custom controller with a custom action following the Api Platform Documentation (https://api-platform.com/do... for the pagination but the hydra:member of the JSON LD response is completely empty. The other fields of the pagination are correct but this one no.

Can somebody help me?

Thank you!

Raul

Reply

Hey Raul M.!

I don't have a lot of experience with custom controllers & actions. The fact that the other pagination fields are correct, but hydra:member is empty is super odd. My best advice for debugging is to try to use the paginator in your custom controller with some debug code to make sure it works. So, something like:


// ...
$paginator = new Paginator($doctrinePaginator);
foreach ($paginator as $result) {
    dump($result);
}
die;

We're checking to make sure that you can correctly iterate over the paginator and see the results. It's just a guess to try to figure out what's going wrong :).

Cheers!

Reply
Raul M. Avatar

Hi Ryan,

I tried to implement your custom paginator and it works, thanks!

Now I'am in hurry with my project but I will try the debbug during the next weeks because it is so strange.

Cheerio!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice