Buy Access to Course
12.

Relating Custom ApiResources

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Inside DailyQuest, add a new property: public array $treasures.

48 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 25
class DailyQuest
{
// ... lines 28 - 34
public array $treasures = [];
// ... lines 36 - 46
}

This will hold an array of dragon treasures that you can win if you complete this quest: treasures like a fancy magician's hat... a talking frog... the world's second largest slinky... or all four corner pieces of a brownie! Mmmmmm...

Adding an array Relations Property

In PHP land, this is just like any other property. Over in our provider, populate it: $quest->treasures = ... and then we'll set that to something. Instead of a boring empty array, we need some DragonTreasure objects. Up at the top, add public function __construct() to autowire a private DragonTreasureRepository $treasureRepository.

54 lines | src/State/DailyQuestStateProvider.php
// ... lines 1 - 9
use App\Repository\DragonTreasureRepository;
class DailyQuestStateProvider implements ProviderInterface
{
public function __construct(
private DragonTreasureRepository $treasureRepository,
)
{
}
// ... lines 19 - 52
}

Below, grab some treasures: $treasures = $this->treasureRepository->findBy() passing an empty array for the criteria - so it'll return everything - no orderBy, and a limit of 10.

54 lines | src/State/DailyQuestStateProvider.php
// ... lines 1 - 9
use App\Repository\DragonTreasureRepository;
class DailyQuestStateProvider implements ProviderInterface
{
public function __construct(
private DragonTreasureRepository $treasureRepository,
)
{
}
// ... lines 19 - 30
private function createQuests(): array
{
$treasures = $this->treasureRepository->findBy([], [], 10);
// ... lines 34 - 51
}
}

Yea, we're just finding the first 10 treasures in the database. I'll paste in some boring code that will grab a random set of these DragonTreasure objects. Put that onto the treasures property.

54 lines | src/State/DailyQuestStateProvider.php
// ... lines 1 - 9
use App\Repository\DragonTreasureRepository;
class DailyQuestStateProvider implements ProviderInterface
{
public function __construct(
private DragonTreasureRepository $treasureRepository,
)
{
}
// ... lines 19 - 30
private function createQuests(): array
{
$treasures = $this->treasureRepository->findBy([], [], 10);
// ... lines 34 - 35
for ($i = 0; $i < 50; $i++) {
// ... lines 37 - 43
$randomTreasuresKeys = array_rand($treasures, rand(1, 3));
$randomTreasures = array_map(fn($key) => $treasures[$key], (array) $randomTreasuresKeys);
$quest->treasures = $randomTreasures;
// ... lines 47 - 48
}
// ... lines 50 - 51
}
}

Cool! And, even though we don't care right now, to make sure our test keeps passing, at the top here, add DragonTreasureFactory::createMany(5)... because if there are zero treasures, weird things will happen in our provider... and the dragons will stage their fiery uprising.

// ... lines 1 - 4
use App\Factory\DragonTreasureFactory;
// ... lines 6 - 8
class DailyQuestResourceTest extends ApiTestCase
{
// ... lines 11 - 13
public function testPatchCanUpdateStatus()
{
// quests need at least some treasures to be available
DragonTreasureFactory::createMany(5);
// ... lines 18 - 29
}
}

Ok, does this new property show up in our API? Head to /api/quests.jsonld to see.. a familiar error:

You must call setIsOwnedByAuthenticatedUser() before isOwnedByAuthenticatedUser().

We know this: it comes from DragonTreasure... all the way at the bottom.

278 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 93
class DragonTreasure
{
// ... lines 96 - 263
public function isOwnedByAuthenticatedUser(): bool
{
if (!isset($this->isOwnedByAuthenticatedUser)) {
throw new \LogicException('You must call setIsOwnedByAuthenticatedUser() before isOwnedByAuthenticatedUser()');
}
return $this->isOwnedByAuthenticatedUser;
}
// ... lines 272 - 276
}

Apparently, the serializer is trying to access this field, but we never set it... which makes sense... because the provider and processor for DragonTreasure aren't called when we're using a DailyQuest endpoint.

Why The Relation is Embedded

But... hold on a second. This shouldn't even be a problem. Let me show you what I mean. To temporarily silence this error, and understand what's going on, find that property... there it is... and give it a default value of false.

278 lines | src/Entity/DragonTreasure.php
// ... lines 1 - 93
class DragonTreasure
{
// ... lines 96 - 147
private bool $isOwnedByAuthenticatedUser = false;
// ... lines 149 - 276
}

Spin over, refresh, and... whoa! It works! Here's our daily quest... and here are the treasures. But... this is not, quite what we expected. Each treasure is an embedded object.

Remember: when you have a relationship to an object that is an ApiResource, like DragonTreasure, that object should only be embedded if the parent class and child class share serialization groups. Like, if we had normalizationContext with groups set to quest:read like this... where the quest:read group is above $treasures, and, in DragonTreasure, we had at least one property that also had quest:read on it.

But, if you do not have this situation - heck, we're not using groups at all - then the serializer should render each DragonTreasure as an IRI string. This should be an array of strings not embedded objects!

The problem is that the serializer looks at this $treasures property and doesn't realize that it holds an array of DragonTreasure objects. It knows it's an array, but before it starts serializing, it doesn't know what is inside. And so, instead of sending them through the system that serializes ApiResource objects, it sends them through the code that serializes normal objects... which results in it just serializing all the properties.

This isn't a problem with entities because the serializer is smart: it reads the Doctrine relationship metadata to figure out that a property is a collection of some other #[ApiResource] object. Long story short, this is simple to fix... it's just hard to understand at first. Above the property, add some PHPDoc to help the serializer: @var DragonTreasure[].

52 lines | src/ApiResource/DailyQuest.php
// ... lines 1 - 9
use App\Entity\DragonTreasure;
// ... lines 11 - 26
class DailyQuest
{
// ... lines 29 - 35
/**
* @var DragonTreasure[]
*/
public array $treasures = [];
// ... lines 40 - 50
}

Try it now... bam! We get IRI strings! I won't bother, but we could undo the default value we added because this object won't be serialized...which is what gave us this error in the first place.

So, other than the embedded object surprise, adding relations to our custom resource is no biggie! Next: instead of embedding DragonTreasure objects directly, let's see how we can invent a new class and new data structure to represent these treasures.