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 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.
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 interfaceTraversable
as part of eitherIterator
orIteratorAggregate
.
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.
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.
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!
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
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!
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!
// 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
}
}
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!