Custom Item Data Provider

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've successfully used our collection data provider to populate the isMe field on each User resource returned by the collection operation:

... lines 1 - 10
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 13 - 21
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
/** @var User[] $users */
$users = $this->collectionDataProvider->getCollection($resourceClass, $operationName, $context);
$currentUser = $this->security->getUser();
foreach ($users as $user) {
$user->setIsMe($currentUser === $user);
}
return $users;
}
... lines 34 - 38
}

If we go to /api/users.jsonld, we see it!

But if we go to an individual item, we get this error... because the isMe field is not being set yet. We can also see this in our tests - the testGetUser() method looks for the isMe field on an item endpoint. Try it:

symfony php bin/phpunit --filter=testGetUser

And this should fail with the same error as the browser. Hmm, it does fail... but not quite like I expected. Ah, I need to make my test smarter. Before checking the JSON, add $this->assertResponseStatusCodeSame(200):

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 47
public function testGetUser()
{
... lines 50 - 54
$client->request('GET', '/api/users/'.$user->getId());
$this->assertResponseStatusCodeSame(200);
... lines 57 - 77
}
}

Now when we try the test:

symfony php bin/phpunit --filter=testGetUser

Yes! It was failing with the same 500 error... and now it's more obvious.

Anyways, to make this test pass, we also need to populate the isMe field when a single User is fetched. Yep, we have a collection data provider, now we need an item data provider.

You can do this in a separate class, but it's totally fine to put both providers in the same file.

Creating the Item Data Provider

Add a third interface called - wait for this mouthful - DenormalizedIdentifiersAwareItemDataProviderInterface:

... lines 1 - 6
use ApiPlatform\Core\DataProvider\DenormalizedIdentifiersAwareItemDataProviderInterface;
... lines 8 - 11
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 14 - 43
}

Wow. If you jump into this class, it extends a less strict ItemDataProviderInterface. I'm implementing that other crazy interface because that's what the core Doctrine item data provider uses... and I want to be able to pass it the same arguments.

Ok: go to the "Code"->"Generate" menu - or Command+N on a Mac - and implement the one method we need: getItem():

... lines 1 - 11
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 14 - 35
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
}
... lines 39 - 43
}

Then let's immediately inject the core Doctrine item provider. Add the new argument: ItemDataProviderInterface $itemDataProvider. Hit Alt+Enter to initialize that property and then... because I'm a bit obsessive about order, I'll make sure this is the second property:

... lines 1 - 7
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
... lines 9 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... line 15
private $itemDataProvider;
... lines 17 - 18
public function __construct(CollectionDataProviderInterface $collectionDataProvider, ItemDataProviderInterface $itemDataProvider, Security $security)
{
... line 21
$this->itemDataProvider = $itemDataProvider;
... line 23
}
... lines 25 - 47
}

Finally, down in getItem(), return $this->itemDataProvider->getItem() - yes, I totally just forgot the itemDataProvider part, I'll catch that in a second - and pass this $resourceClass, $id, $operationName and also $context:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 38
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
return $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
}
... lines 43 - 47
}

To tell Symfony to specifically pass us the Doctrine item provider, go back to services.yaml: we need to add one more bind. The name of the argument is $itemDataProvider - so I'll copy that - and that should be passed the same service id as above, but with the name "item". If you used debug:container, that's what you would see:

... lines 1 - 7
services:
... lines 9 - 47
App\DataProvider\UserDataProvider:
bind:
$collectionDataProvider: '@api_platform.doctrine.orm.default.collection_data_provider'
$itemDataProvider: '@api_platform.doctrine.orm.default.item_data_provider'

Beautiful! Before we try this, let's jump straight in and set the isMe field. To do that, instead of returning, add $item = and I'll - as usual - put some phpdoc above this because we know this will be a User object or null:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 38
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** @var User|null $item */
$item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
... lines 43 - 50
}
... lines 52 - 56
}

It will be null if the id wasn't found in the database. To prevent that being a problem, if not, $item, then, return null:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 38
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** @var User|null $item */
$item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
if (!$item) {
return null;
}
... lines 47 - 50
}
... lines 52 - 56
}

At this point, we know we have a User object. So we can say $item->setIsMe() $this->security->getUser() === $item. Finish by returning $item at the bottom:

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 38
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
/** @var User|null $item */
$item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
if (!$item) {
return null;
}
$item->setIsMe($this->security->getUser() === $item);
return $item;
}
... lines 52 - 56
}

Ok! We're ready to try the test:

symfony php bin/phpunit --filter=testGetUser

And... ah! Recursion! The problem is coming from line 43 of UserDataProvider. That's the dummy mistake I made a few minutes ago: add itemDataProvider-> and then getItem():

... lines 1 - 12
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, DenormalizedIdentifiersAwareItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 15 - 38
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = [])
{
... line 41
$item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
... lines 43 - 50
}
... lines 52 - 56
}

Come on Ryan!

Now when we try it:

symfony php bin/phpunit --filter=testGetUser

Green! Congrats team! We just added a custom isMe field that is returned on both the item and collection operations and is properly documented.

Don't Forget Populating in the Data Persister

But... surprise! There is one last spot where the data is missing! Open UserResourceTest and go to the top: the first test is about creating a user. Copy that method name and run the tests with --filter=testCreateUser:

symfony php bin/phpunit --filter=testCreateUser

Ah! A 500 error! Apparently the isMe field in this User object is never set!

And... if you think about it... that makes sense! This is the one situation where the data provider system is never called! It's called when you load a collection of items or a single item, but when you're creating a new item, there was never anything to load!

The fix for this is to also make sure that we set this field in the data persister. At the top, add one more argument - Security $security - and then initialize that property:

... lines 1 - 9
use Symfony\Component\Security\Core\Security;
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 14 - 16
private $security;
public function __construct(DataPersisterInterface $decoratedDataPersister, UserPasswordEncoderInterface $userPasswordEncoder, LoggerInterface $logger, Security $security)
{
... lines 21 - 23
$this->security = $security;
}
... lines 26 - 63
}

Below, in persist() - we could add the logic in the if statement where we know this is a new item - but it doesn't hurt to set it every time a User is saved. Add $data->setIsMe($this->security->getUser() === $data):

... lines 1 - 11
class UserDataPersister implements ContextAwareDataPersisterInterface
{
... lines 14 - 34
public function persist($data, array $context = [])
{
... lines 37 - 54
$data->setIsMe($this->security->getUser() === $data);
return $this->decoratedDataPersister->persist($data);
}
... lines 59 - 63
}

Try the test now:

symfony php bin/phpunit --filter=testCreateUser

We got it!

What we just did is a really natural way to use entities in API Platform... but also have the flexibility to add custom fields that require a service to set their value.

But, I admit, it's not the easiest thing. This required both a collection and item data provider... and we also needed to add the same code in a data persister.

What I love about this solution is that the field isn't really custom: it's a real field that we add to our ApiResource class. The tricky part is populating that field. And it turns out, there are several other ways to do this, which might be better depending on the situation. Let's talk about those next.

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