Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

9
Login or Register to join the conversation

Hey there, you used bind instead of arguments when injecting

api_platform.doctrine.orm.default.item_data_provider service

, it was for a reason, is it a good practice ?

Reply

Hey Ahmedbhs,

Yeah, this is a good practice actually, we used this approach in our code as well. The exact variable name in the constructor args will requires Symfony to inject the specific service, that's how Symfony DI works behind the scene - it looks for the specific var name from bind :)

Another alternative solution would be to declare the service manually in services.yaml and specify passing the "@api_platform.doctrine.orm.default.item_data_provider" service manually.

Both should work, but usually devs using bind feature.

Cheers!

Reply
Default user avatar
Default user avatar mwozniak | posted 1 year ago

Hello, I've got error:
"@type": "hydra:Error",
"hydra:description": "$id must be array when "has_identifier_converter" key is set to true in the $context"

at: $item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);
should it be: $item = $this->itemDataProvider->getItem($resourceClass, ['id' => $id], $operationName, $context);?

Where does has_identifier_converter comes from?

Reply

Hey @mwozniak!

Sorry for my slow reply - sometimes the deeper questions get left to me (and this is a good one!) and I was deep in tutorial land :). Now, let's see!

So this is not an area that I'm very familiar with. But here's what happens internally. Let's assume you hav this code:


$item = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, $context);

When you call this, you almost immediately hit this code: https://github.com/api-plat...

In most situations, where the $id you pass in is a string or int, the normalizeIdentifiers() method is called. You can find that here: https://github.com/api-plat...

That method takes your string id and converts it into an array - for example ['id' => 4].

Then, back in getItem(), if $id is not an array at this point, you hit the exception you're seeing: https://github.com/api-plat...

The question is... why is this happening? But first, let me answer this:

> Where does has_identifier_converter comes from?

If you're using an id in your system that is... somehow custom (e.g. maybe you use an id that is the combination of a "slug" field, a dash, then the id $slug-$id), then you need to register a custom "identifier converter" that is capable of understanding this (i.e. splitting it into 2 pieces). This is not a context key that you would normally set yourself manually. It is a custom class you create, which API platform detects early in the request. It then sets this flag for you, so that it can handle your id conversion correctly.

So. my guess is that this is NOT what you're trying to do. In your case, something is going wrong with the "id" normalization, and the error you're getting isn't the best one. A better error for you would be:

> Something went wrong trying to convert your id $id.

I know - still not that helpful - because we don't know what the problem here. So here is what I would do:

A) Add some debugging code right around here https://github.com/api-plat... - to make sure that you ARE getting inside that if statement.

B) Assuming you ARE getting into that if statement, add some debugging code to the normalizeIdentifiers() method to see why this isn't returning an array.

My guess is that you are in situation (A). That means that either:

1) You are passing an. $id that is not an int or string
2) Somehow, that has_identifier_converter IS being set to true by something.

Let me know what you find out!

Cheers!

Reply
Szabolcs Avatar
Szabolcs Avatar Szabolcs | posted 1 year ago

Hello There! If I write an custom DataProvider (maybe for User-Enities like in this example), than I will loose all the features like the QueryExtensions and OrmFilters. So this is the reason, why in this example the original DataProviders (Collection- and ItemProvider) was injected. But in this example these decorated DataProviders returns the query-result, but I see no way here to make joins, which will be often the case!. If I want to make a join, should I write an QueryExtension, which is used in the decorated DataProvider? This is very weird :-D. What is the recommendation here? :) Thanks!

Reply

Hi Szabolcs!

Sorry for my VERY slow reply - your question was left for me, and I've been lost in tutorial-creation land for a week or so :).

Ok, excellent question!

> If I write an custom DataProvider (maybe for User-Enities like in this example), than I will loose all the features like the QueryExtensions and OrmFilters. So this is the reason, why in this example the original DataProviders (Collection- and ItemProvider) was injected

Yes! Exactly! If we did NOT inject the original data providers (and instead, we simply queried for the object(s) ourself), we would be "avoiding" the query extension system, which also gives us things like pagination. So you are understanding this perfectly

> But in this example these decorated DataProviders returns the query-result, but I see no way here to make joins, which will be often the case!

Correct! The query has already been executed - so it's too late to change the query.

> If I want to make a join, should I write an QueryExtension, which is used in the decorated DataProvider

Yup! QueryExtension is your way to *modify* a query, which, in this example, will then be used by our decorated DataProvider. i know, it's complicated :). The QueryExtension and DataProvider have *slightly* different "superpowers". The QueryExtension can modify the query before it's executed, but it can't "modify" the entity objects that are returned, because the query hasn't actually be executed at that point. The DataProvider can't modify the query, but it can modify each entity object. That's why each serves a slightly different purpose.

Cheers!

Reply
Szabolcs Avatar

Thank you for your answer! I have wrote an AbstractDataProvider which
allows access to the current query-builder. I believe, to be able to
make joins really belongs in a data-provider. QueryExtensions for me are
a place, where I put more "common things" like a "NotDeletedExtension"
and so on. Can I talk with Kevin Dunglas please? I just kidding :-p! I
really love your tutorial by the way, I'm watching it even when I'm on
vacation :-)! Best wishes!

1 Reply
Ahmed O. Avatar
Ahmed O. Avatar Ahmed O. | posted 1 year ago

Hello, shouldn't the injected collectionDataProvider be ContextAware also ?

Reply

Hey Ahmed,

if you're not going to work with the $context then it's not needed to implement that interface but it doesn't cause any harm if you do

Cheers!

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": "^7.4.0 || ^8.0",
        "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
    }
}