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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeI 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.
Seems that the Jetbrains devs heard what Ryan said so current PhpStorm versions already know about the PSR-5 syntax and will yell about a duplicated declaration if you add the second one. Autocomplete works of course ;)