Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Completely Custom Field via a 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

Most of the time, the fields in our API match the properties in our entity. But we know that doesn't always need to be the case. In the previous tutorial, there were several times when we needed to add a custom field. For example, in CheeseListing, we added a shortDescription field to the API by adding a getShortDescription() method and putting it in the cheese:read group:

... lines 1 - 58
class CheeseListing
{
... lines 61 - 135
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
... lines 147 - 217
}

We did the same with setTextDescription(): this added a textDescription field that could be written in the API:

... lines 1 - 58
class CheeseListing
{
... lines 61 - 154
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
... lines 167 - 217
}

Custom Fields that Require a Service

But you can only take this so far. If you need a service to calculate the value of a custom field, then you can't use this trick because these classes don't have access to services.

We even tackled this in the previous tutorial! We added an isMe boolean field to User. We did this by leveraging a custom normalizer: src/Serializer/Normalizer/UserNormalizer:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
... lines 30 - 38
$data['isMe'] = $isOwner;
... lines 40 - 41
}
... lines 43 - 69
}

We basically added an extra field right before the array is turned into JSON.

But this solution had a flaw. Look at the documentation on the User resource: I'll open the "get" operation and, down here, you can see the schema of what's going to be returned. It does not mention isMe! This field would be there if you tried the endpoint, but our docs have no idea it exists.

And... maybe you don't care! Not every app needs to have perfect docs. But if we do care? How could we add a custom field that's properly documented?

There are a few ways, like creating a completely custom API resource class or using an output DTO. We'll talk about both of these later. But in most situations, there is a simpler solution.

In UserResourceTest, if you find testGetUser(), we do have a test that looks for the isMe field. It's false in the first test and true after we log in as this user:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 47
public function testGetUser()
{
$client = self::createClient();
$user = UserFactory::new()->create(['phoneNumber' => '555.123.4567']);
$authenticatedUser = UserFactory::new()->create();
$this->logIn($client, $authenticatedUser);
$client->request('GET', '/api/users/'.$user->getId());
$this->assertJsonContains([
'username' => $user->getUsername(),
]);
$data = $client->getResponse()->toArray();
$this->assertArrayNotHasKey('phoneNumber', $data);
$this->assertJsonContains([
'isMe' => false,
]);
// refresh the user & elevate
$user->refresh();
$user->setRoles(['ROLE_ADMIN']);
$user->save();
$this->logIn($client, $user);
$client->request('GET', '/api/users/'.$user->getId());
$this->assertJsonContains([
'phoneNumber' => '555.123.4567',
'isMe' => true,
]);
}
}

Copy that method name... and let's just make sure it's passing:

symfony php bin/phpunit --filter=testGetUser

Right now... it does pass. Time to break things! In UserNormalizer, remove the isMe property. Actually, we can just return $this->normalizer->normalize() directly:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
$isOwner = $this->userIsOwner($object);
if ($isOwner) {
$context['groups'][] = 'owner:read';
}
$context[self::ALREADY_CALLED] = true;
return $this->normalizer->normalize($object, $format, $context);
}
... lines 39 - 65
}

This class still adds a custom group, but it no longer adds a custom field. Try the test now:

symfony php bin/phpunit --filter=testGetUser

A lovely failure!

Adding a Non-Persisted Api Field

Ok: so what is the other way to solve this? It's beautifully simple. The idea is to create a new property inside of User but not persist it to the database. The new property will hold the custom data and then we will expose it like a normal field in our API.

Yep. It's that easy! Oh, but there is one tiny, annoying problem: if this field isn't stored in the database... and we need a service to calculate its value, how do we set it?

Great question. And... there are about 47 different ways. Okay, not 47, but there are a few ways and we'll look into several of them because different solutions will work best in different situations.

Hello Data Providers

For the first solution, let's think about how API Platform works. When we make a request to /api/users or, really, pretty much any endpoint, API Platform needs to somehow load the object or objects that we're requesting.

It does that with its "data provider" system. So, we have data persisters for saving stuff and data providers for loading stuff. There are both collection data providers for loading a collection of objects and item data providers that load one object for the item operations.

Normally, we don't need to think about this system because API Platform has a built-in Doctrine data provider that handles all of it for us. But if we want to load some extra data, a custom data provider is the key.

Creating the Data Provider

In the src/ directory, let's see, make a new DataProvider/ directory and, inside, create a new PHP class called UserDataProvider:

... lines 1 - 2
namespace App\DataProvider;
... lines 4 - 7
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 10 - 16
}

The idea is that this class will take full responsibility for loading user data... well, for now, just the "collection" user data. Add two interfaces: ContextAwareCollectionDataProviderInterface - yep, that's a huge name - and also RestrictedDataProviderInterface:

... lines 1 - 2
namespace App\DataProvider;
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface;
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 10 - 16
}

Before we talk about these, I'll go to the "Code"->"Generate" menu - or Command+N on a Mac - click "Implement Methods" and select both methods that are needed:

... lines 1 - 7
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
}
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
}
}

So, to create a collection data provider, the only interface that you should need - in theory - is something called CollectionDataProviderInterface. If you jump into ContextAwareCollectionDataProviderInterface, it extends that one.

We've seen this kind of thing before: there's the main interface, and then an optional, stronger interface. If you implement the stronger one, it adds the $context argument to the getCollection() method. So if your data provider needs the context, you need to use this interface. I'll show you why we need the context in a few minutes.

The RestrictedDataProviderInterface is actually what requires the supports() method. If we didn't have that, API Platform would use our data provider for every class, not just the User class. Let's add our logic: return $resourceClass === User::class:

... lines 1 - 6
use App\Entity\User;
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 11 - 14
public function supports(string $resourceClass, string $operationName = null, array $context = []): bool
{
return $resourceClass === User::class;
}
}

Perfect!

Up in getCollection() - as its name suggests - our job is to return the array of users. So... simple, right? We just need to query the database and return every User.

Well, that's not going to be quite right, but it's close enough for now. Add a public function __construct() and autowire UserRepository $userRepository. I'll do my Alt+Enter trick and go to "Initialize Properties" to create that property and set:

... lines 1 - 7
use App\Repository\UserRepository;
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
... lines 18 - 27
}

Now, in getCollection(), return $this->userRepository->findAll():

... lines 1 - 9
class UserDataProvider implements ContextAwareCollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 12 - 18
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
return $this->userRepository->findAll();
}
... lines 23 - 27
}

Sweet! Let's try it. We still haven't added the isMe field, so instead of trying the test, let's do this in the browser. Go to /api/users.jsonld.

And... oh! If you get "full authentication is required", that's our security system in action! Go team! In another tab, I'll go back to my homepage and hit log in. Refresh the original tab again and... sweet! It's working!

But... is it actually using our new data provider? Well... if you look closely, you can see that we lost something: pagination! Oh, I'm totally looking at the wrong spot here - these are the embedded cheeses for the first user. Scroll down Ryan! Bah! Well, if I had scrolled down we would have seen that all 50 users are being listed. But normally, pagination only shows 30 at a time.

So... this proves that our data provider is being used. And that's thanks to autoconfiguration: if you create a service that implements CollectionDataProviderInterface, API Platform will automatically find it and start calling its supports() method.

So that's great... but the fact that we lost pagination and filtering is... not so great. Our API documentation still advertises that pagination and filtering exist, but it's a lie. None of that would work.

It turns out that the core Doctrine data provider is also responsible for reading the page and filter query parameters and changing the query based on them.

We don't want to lose that just to add a custom field. So next, let's use our favorite trick - class decoration - to get that functionality back. Then we can add our custom field.

Leave a comment!

7
Login or Register to join the conversation
Petru L. Avatar
Petru L. Avatar Petru L. | posted 1 year ago

Hello,

I have a custom field in my File entity which i set in my DataProvider. This field can only be read and by default it is null and while it does show correctly on my File resource, it doesn't show in a response of a different resource that has a relation to the File entity. For example, the File entity is a OneToMany to a ProductFile join table which also has a relation of ManyToOne to Product Entity. When i make a GET request to Product resource i get a list of products each with a productFiles array of files objects and in this object the custom field is missing. After some debugging it looks like is missing because apiplatform doesn't display null properties by default, so the problem is that my custom field isn't set.. actually the File data provider isn't used when the file is a sub resource of a another resource. Any ideas?

Reply

Hey Petru L. !

Sorry for my VERY late reply - your question was left for me and I've been deep in tutorial land :).

Ok, I think I can answer this :). Here's the key:

> custom field in my File entity which i set in my DataProvider ... while it does show correctly on my File resource, it doesn't show in a response of a different resource that has a relation to the File entity

The "data provider" system is only called ONCE per request for the TOP level resource only. So when you request directly for a "File" resource, it's called and everyone is happy :). But if you request to a Product resource, in order to get the "productFiles" field, API Platform simply calls $product->getProductFiles(), which uses 100% Doctrine logic. Then, for each related file, it does the same thing, calling $productFile->getFile(), once again using normal Doctrine logic and NOT the data provider.

This is definitely a gotcha... but I can't remember if I mention in it in the tutorial or not (I hope I do somewhere!).

Anyways, the fix - if you need it to show up in these embedded situations as well - is probably to use a Doctrine listener - like we do here - https://symfonycasts.com/sc.... Other solutions involve adding a listener - https://symfonycasts.com/sc... - or other data provider to set this. But in both of those situations, you would need to have some logic that says "Oh! The main object is a Product. I know that this will include a File, so let me loop over the $product->getProductFiles(), then get each File object, and then set the property". That's not a huge deal - but if you ever had a File that was included as an embedded resource in yet *another* resource, you'd need to update the logic to handle THAT spot too.

Let me know what you think :). I tend to limit my "resource embedding" to avoid this sort of issue. But obviously, sometimes embedding data is exactly what you want in your API. And we CAN make this work.

Cheers!

Reply
Petru L. Avatar
Petru L. Avatar Petru L. | weaverryan | posted 1 year ago | edited

Hey weaverryan, no worries, thank you for the reply.
I do get what your saying and in the meantime i end up using an output dto which works for embedded data, haven't tried with more than 1 level though. So i guess i'll just leave it like that until i face another complication.

Also, this Disqus comment system has some real problems lately... issues with suggestions where it keeps the pre suggestion string along with the completion, tags that are not correctly rendered...

Reply

Hey Petru L.

Thanks for your input about Disqus. We're aware of it's far from perfect, and it's on our goal to switch to a different system but I can't give any estimation about it

Cheers!

Reply
Kakha K. Avatar
Kakha K. Avatar Kakha K. | posted 1 year ago

Hi All

When I am testing, always shows


{"error":"Invalid credentials."}

And I cannot test, just watching videos as demo

I did copy and paste all code but no result


[kakhaber@localhost code-api-platform-extending]$ php bin/phpunit --filter=testGetUser
PHPUnit 8.2.5 by Sebastian Bergmann and contributors.


Testing Project Test Suite
F 1 / 1 (100%)


Time: 2.29 seconds, Memory: 30.00 MB


There was 1 failure:


1) App\Tests\Functional\UserResourceTest::testGetUser
Failed asserting that the Response status code is 204.
HTTP/1.1 401 Unauthorized
Cache-Control: max-age=0, must-revalidate, private
Content-Type: application/json
Date: Wed, 17 Feb 2021 15:08:48 GMT
Expires: Wed, 17 Feb 2021 15:08:48 GMT
Link: <http: example.com="" api="" docs.jsonld="">; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"
X-Robots-Tag: noindex


{"error":"Invalid credentials."}


/home/kakhaber/Projects/symfonycasts/code-api-platform-extending/vendor/api-platform/core/src/Bridge/Symfony/Bundle/Test/BrowserKitAssertionsTrait.php:38
/home/kakhaber/Projects/symfonycasts/code-api-platform-extending/src/Test/CustomApiTestCase.php:56
/home/kakhaber/Projects/symfonycasts/code-api-platform-extending/tests/Functional/UserResourceTest.php:54


FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
Reply

Hey Kakha K.

That's a bit odd. I believe your requests are been processed in the "dev" environment. If you're using Symfony CLI to spin-up your web server, then start it in this way and give it a try
APP_ENV=test symfony serve -d

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