Why/When a Many Relation is IRI Strings vs Embedded

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

I want to show you something a bit surprising about how our custom API resource is being serialized. To make things more realistic, add a $stats2, and let's actually use 1000 visitors for day one - our site is at least kind of popular - 2000 for $stats2 and set this to minus 1 days from now. Put that new $stats2 into the return statement:

... lines 1 - 8
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
public function getCollection(string $resourceClass, string $operationName = null)
{
$stats = new DailyStats(
new \DateTime(),
1000,
[]
);
$stats2 = new DailyStats(
new \DateTime('-1 day'),
2000,
[]
);
return [$stats, $stats2];
}
... lines 27 - 31
}

Both of these DailyStats are missing one thing: we're passing an empty array as the fourth argument, which is supposed to be a collection of the most popular CheeseListing objects. I don't really care about the "most popular" part, so let's just pass 5 random listings.

Create a public function __construct() and autowire CheeseListingRepository. I'll hit Alt+Enter and go to "Initialize properties" to create that property and set it:

... lines 1 - 7
use App\Repository\CheeseListingRepository;
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
private $cheeseListingRepository;
public function __construct(CheeseListingRepository $cheeseListingRepository)
{
$this->cheeseListingRepository = $cheeseListingRepository;
}
... lines 18 - 42
}

Before the stats, fetch some listings with $listings = $this->cheeseListingRepository->findBy() and pass an empty array of criteria, an empty array of "order by" and 5 to get a max of 5 listings:

... lines 1 - 9
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 12 - 18
public function getCollection(string $resourceClass, string $operationName = null)
{
$listings = $this->cheeseListingRepository
->findBy([], [], 5);
... lines 23 - 36
}
... lines 38 - 42
}

Use this to replace the empty array: $listings and $listings:

... lines 1 - 9
class DailyStatsProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface
{
... lines 12 - 18
public function getCollection(string $resourceClass, string $operationName = null)
{
$listings = $this->cheeseListingRepository
->findBy([], [], 5);
$stats = new DailyStats(
new \DateTime(),
1000,
$listings
);
$stats2 = new DailyStats(
new \DateTime('-1 day'),
2000,
$listings
);
... lines 35 - 36
}
... lines 38 - 42
}

Embedded Serialization Versus Inline IRI

Easy enough! Let's see what it looks like! Back at the browser, refresh and... no surprise! It works! Wait... it looks... weird. Each listing has @id and @type... but no other fields. And... hmm. That actually makes sense.

In DailyStats, to serialize - or "normalize" - we're using a group called daily-stats:read:

... lines 1 - 23
class DailyStats
{
/**
* @Groups({"daily-stats:read"})
*/
public $date;
... lines 30 - 59
}

Whenever API Platform sees an embedded object:

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
* The 5 most popular cheese listings from this date!
*
* @Groups({"daily-stats:read"})
*/
public $mostPopularListings;
... lines 42 - 59
}

It looks at that object - so CheeseListing - and looks to see if any of its fields are in that same group. And there are no properties inside CheeseListing that have the daily-stats:read group:

... lines 1 - 58
class CheeseListing
{
... lines 61 - 67
/**
... line 69
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 71 - 76
*/
private $title;
/**
... line 81
* @Groups({"cheese:read"})
... line 83
*/
private $description;
/**
... lines 88 - 90
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 92
*/
private $price;
... lines 95 - 100
/**
... line 102
* @Groups({"cheese:write"})
*/
private $isPublished = false;
/**
... lines 108 - 109
* @Groups({"cheese:read", "cheese:collection:post"})
... line 111
*/
private $owner;
... lines 114 - 217
}

So... it makes sense that when we embed CheeseListing into DailyStats, none of its fields are included.

But, hold on a minute. Usually when API Platform sees an embedded object... and sees that no fields will be exposed on that object, instead of embedding it, it uses its IRI.

So the question is: why is it not doing that? Why do we have an array of embedded objects instead of an array of IRI strings?

API Platform Doesn't know Your Collection Type

Before we answer that, let's first see how to solve it. Look closely at the $mostPopularListings property:

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
* The 5 most popular cheese listings from this date!
*
* @Groups({"daily-stats:read"})
*/
public $mostPopularListings;
... lines 42 - 59
}

From API Platform's perspective, it knows that this will hold an array, but it does not know that it will hold an array of CheeseListing objects:

... lines 1 - 23
class DailyStats
{
... lines 26 - 42
/**
* @param array|CheeseListing[] $mostPopularListings
*/
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
... lines 48 - 49
$this->mostPopularListings = $mostPopularListings;
}
... lines 52 - 59
}

And that changes how it behaves.

Ok, in truth, this CheeseListing[] doc should probably be enough to tell API Platform that this is an array of CheeseListing objects:

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

But for some reason it doesn't read that syntax.

It's not important, but let's move the documentation to the property directly. Say @var array then greater than, less than with CheeseListing inside:

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
... lines 37 - 38
* @var array<CheeseListing>
... line 40
*/
public $mostPopularListings;
public function __construct(\DateTimeInterface $date, int $totalVisitors, array $mostPopularListings)
{
... lines 46 - 48
}
... lines 50 - 57
}

This syntax is... maybe a bit lesser-known than the CheeseListing[] syntax. It comes from PSR-5 - a draft standard for PHP documentation.

As soon as we make this change... when we refresh... check it out! Suddenly it is the array of IRI strings that we expected! If we added the daily-stats:read group to any of the properties in CheeseListing, it would become an embedded object, but since we haven't, we get the expected IRI strings.

Now... the only problem with the array<CheeseListing> syntax is that... PhpStorm doesn't understand it. If we say $this->mostPopularListings[0]->... we get zero auto-completion.

The workaround is to use both formats: this is an array of CheeseListing or CheeseListing[]:

... lines 1 - 23
class DailyStats
{
... lines 26 - 35
/**
... lines 37 - 38
* @var array<CheeseListing>|CheeseListing[]
... line 40
*/
public $mostPopularListings;
... lines 43 - 57
}

And now PhpStorm is happy. This isn't ideal, but hopefully we won't need both formats in the future.

But... hold on. We just added documentation that changed the way API Platform serialized our object. Why was that needed? Can't API Platform figure out that this is an array of CheeseListing objects when we... pass it an array of CheeseListing objects? Why was the extra documentation even needed?

Let's dive deeper into that question 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
        "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
    }
}