Property Metadata

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

Thanks to the data collection provider, our endpoint returns one result... but there are no fields! Why not?

Normally if you don't set a normalizationContext - like we did in User with normalizationContext and groups:

... lines 1 - 17
/**
* @ApiResource(
... line 20
* normalizationContext={"groups"={"user:read"}},
... lines 22 - 34
* )
... lines 36 - 40
*/
class User implements UserInterface
{
... lines 44 - 286
}

Then your object will be serialized with no serialization groups... which basically means that every property will be included.

But... we are not see that at all! This is due to something we did in a previous tutorial. In src/Serializer/AdminGroupsContextBuilder.php, we added code to give you extra groups if you're an admin:

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
$context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
$context['groups'] = $context['groups'] ?? [];
$isAdmin = $this->authorizationChecker->isGranted('ROLE_ADMIN');
if ($isAdmin) {
$context['groups'][] = $normalization ? 'admin:read' : 'admin:write';
}
$context['groups'] = array_unique($context['groups']);
return $context;
}
}

But to do this, if the groups are not set on the $context, we initialized them to an empty array:

... lines 1 - 8
final class AdminGroupsContextBuilder implements SerializerContextBuilderInterface
{
... lines 11 - 19
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
{
... lines 22 - 23
$context['groups'] = $context['groups'] ?? [];
... lines 25 - 34
}
}

Thanks to this, if we don't have a normalization group on the resource, instead of serializing everything, it will serialize nothing because it thinks we want to serialize no groups. It's... a quirk in our project.

Adding Normalization Groups

But, it's no problem because I prefer being explicit with my groups anyways. In other words, in DailyStats, add normalizationContext set to {} and groups equals {"daily-stats:read"}:

... lines 1 - 9
/**
* @ApiResource(
* normalizationContext={"groups"={"daily-stats:read"}},
... lines 13 - 21
* )
*/
class DailyStats
{
... lines 26 - 47
}

That follows a naming convention we've been using. Copy that group name so that we can add it above the properties we want. Above date, put @Groups({}) and paste. Now copy that entire doc block and put it above totalVisitors and also mostPopularListings:

... lines 1 - 7
use Symfony\Component\Serializer\Annotation\Groups;
... lines 9 - 23
class DailyStats
{
/**
* @Groups({"daily-stats:read"})
*/
public $date;
/**
* @Groups({"daily-stats:read"})
*/
public $totalVisitors;
/**
* @Groups({"daily-stats:read"})
*/
public $mostPopularListings;
... lines 40 - 47
}

But we do not need to put this above getDateString(). That is used as our identifier, but we don't need it as a real field in the API:

... lines 1 - 23
class DailyStats
{
... lines 26 - 40
/**
* @ApiProperty(identifier=true)
*/
public function getDateString(): string
{
... line 46
}
}

Ok, let's try it! When we refresh... Symfony politely reminds me that I'm missing a comma in my annotations:

... lines 1 - 9
/**
* @ApiResource(
* normalizationContext={"groups"={"daily-stats:read"}},
... lines 13 - 21
* )
*/
class DailyStats
{
... lines 26 - 47
}

There we go. Now... yes! We have fields!

How Property Metadata Works

Head back to the documentation... find this endpoint, look at the schema, and navigate to the hydra:member property. The docs now show the correct fields! But... it knows nothing about the types of each field. Are these strings? Integers? Aliens?

API Platform gets metadata about each property from many different places, like by reading Doctrine metadata, PHPDoc, looking at the return types of getter methods, looking at the argument type-hint on setters, PHP 7.4 property types and more. What's really neat about this, is that if you code well and document your code, API Platform will intelligently use that for its docs!

This becomes especially important to think about when your class is no longer a Doctrine entity. Why? Because with an entity, API Platform gets a ton of metadata from Doctrine. Without an entity, we need to do more work to fill in the gaps.

Adding Metadata with a Constructor

To tell API Platform the type of each property, we could definitely use PHP 7.4 property types or add @var PHPDoc above each one. But we can also add a constructor. Now, my true motivation for adding a constructor is not really documentation - that's a nice side effect. My true motivation is that I want to make sure that anytime a DailyStats object is instantiated, all three properties are set.

I'll cheat to do this: go to the "Code"->"Generate" menu - or Command+N on a Mac - choose "Constructor" and select all 3 properties. Then fill in the types: DateTimeInterface, int and array:

... lines 1 - 23
class DailyStats
{
... lines 26 - 43
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
$this->date = $date;
$this->totalVisitors = $totalVisitors;
$this->mostPopularListings = $mostPopularListings;
}
... lines 50 - 57
}

I'm also going to remove most of the documentation. This is totally your call, but I usually only include documentation that adds more information: the first two are redundant.

But, hmm, we can add more info about $mostPopularListings. The type-hint tells us that this is an array... but not what will be inside the array. Help it out by setting the type to CheeseListing[]:

... lines 1 - 23
class DailyStats
{
... lines 26 - 40
/**
* @param array|CheeseListing[] $mostPopularListings
*/
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
... lines 46 - 48
}
... lines 50 - 57
}

Now, in DailyStatsProvider, we just need to rearrange all the data into the constructor. Pass an empty array for the popular cheese listings:

... lines 1 - 7
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
$stats = new DailyStats(
new \DateTime(),
1000,
[]
);
... lines 18 - 19
}
... lines 21 - 25
}

I love this! We've written good code and API Platform is going to read our constructor as documentation for the properties! Refresh the docs... open up the operation, look at the schema, go to hydra:member and... awesome! The date is a string that will be formatted as a date-time, totalVisitors is an integer and eventually mostPopularListings will be an array of strings: an array of IRI strings.

Want to add more documentation? We already know how:

The 5 most popular cheese listings from this date!

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
* The 5 most popular cheese listings from this date!
... lines 38 - 39
*/
public $mostPopularListings;
... lines 42 - 59
}

Or even above the constructor.

Oh, and by the way: helping API Platform determine the type of each field is more than just for documentation: it's also used during deserialization. For example, if you send an IRI string to a field that is a CheeseListing type, the denormalization system will correctly convert that IRI string into a CheeseListing object. Similar things happen for date strings and many other types.

And next, when we start returning CheeseListing objects on the mostPopularListings field, we're going to learn another way that property metadata affects how your objects are serialized.

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
        "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.6
        "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
    }
}