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!

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