Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Embedding Custom DTO's

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

One goal of the daily quests resource is to showcase the bountiful treasures a dragon can win by completing a quest. Embedding an array of DragonTreasure objects and showing their IRIs is a nice way to do that! But it's not the only way.

Creating the Custom (non-ApiResource) Class

Idea time: forget about pointing to the exact treasures. What if we simply render the name, cool factor, and value of each as a custom array of embedded data? Check it out. In the src/ApiResource/ directory, though this class could live anywhere, create a new class called DailyQuestTreasure. This will represent the treasure that you could win by completing a DailyQuest.

Inside, create a public function __construct with a public string $name, public int $value and public int $coolFactor. I'm using public properties for simplicity and even including all three as arguments to the constructor to make life even easier.

... lines 1 - 2
namespace App\ApiResource;
class QuestTreasure
public function __construct(
public string $name,
public int $value,
public int $coolFactor,

But, I am not going to make this an ApiResource. Well, we could do that... if we need our API users to be able to fetch DailyQuestTreasure data directly... or update them. But that's not the point of this class. This will simply be a data structure that we attach to DailyQuest.

Over in DailyQuest, this will no longer hold an array of DragonTreasure objects: it will hold an array of QuestTreasure objects. Oh, actually, to keep things shorter... there we go... call it QuestTreasure... then over here, QuestTreasure.

... lines 1 - 26
class DailyQuest
... lines 29 - 35
* @var QuestTreasure[]
public array $treasures = [];
... lines 40 - 50

Now that we have the property set up, head to the provider to populate it. Instead of setting the random dragon treasures onto this directly, we need to create an array of QuestTreasure objects. For each over the random treasures as $treasure... then $questTreasures[] equals new QuestTreasure and pass in the data: $treasure->getName(), $treasure->getValue() and $treasure->getCoolFactor(). Finish with $quest->treasures = $questTreasures.

... lines 1 - 8
use App\ApiResource\QuestTreasure;
... lines 10 - 12
class DailyQuestStateProvider implements ProviderInterface
... lines 15 - 31
private function createQuests(): array
... lines 34 - 36
for ($i = 0; $i < 50; $i++) {
... lines 38 - 46
$questTreasures = [];
foreach ($randomTreasures as $treasure) {
$questTreasures[] = new QuestTreasure(
$quest->treasures = $questTreasures;
... lines 56 - 57
... lines 59 - 60

"Relations" that are Normal Objects

Before and after this change, our DailyQuest class had a property that held an array of objects. The key difference is that, before, it held an array of objects that were API resources. But now, it holds an array of normal, boring objects that are not API resources.

What difference does that make? Check it out. Boom! Embedded objects! When API Platform serializes the treasures property, it sees that our QuestTreasure is not an ApiResource. So it serializes it in the normal way: by embedding each property.

This is beautifully simple. And it's something I want you to remember: you can always create new data classes if you want to embed some extra data.

The .well-known genId

But I bet you noticed this weird @id with .well-known/genId. This... is a randomly-generated string which exists, I believe, because JSON-LD resources are supposed to have an @id. But since we don't really have a place where you can fetch individual Quest Treasures... API Platform gives us this fake one.

Now, in theory, you could turn that off by saying #[ApiProperty()] with genId: false.

... lines 1 - 4
use ApiPlatform\Metadata\ApiProperty;
... lines 6 - 26
class DailyQuest
... lines 29 - 38
#[ApiProperty(genId: false)]
public array $treasures = [];
... lines 41 - 51

Unfortunately, this doesn't seem to work for array properties... maybe I'm doing something wrong. I get that id. But it does work for single objects. To prove it, change this to a single QuestTreasure. We don't need our @var anymore because this now has a proper type.

... lines 1 - 26
class DailyQuest
... lines 29 - 35
#[ApiProperty(genId: false)]
public QuestTreasure $treasure;
... lines 38 - 48

Over in our provider, I'll change a few things super quickly... to get just one random QuestTreasure. Finish with $quest->treasure equals this one QuestTreasure. Use $randomTreasure for all the variable names.

... lines 1 - 12
class DailyQuestStateProvider implements ProviderInterface
... lines 15 - 31
private function createQuests(): array
... lines 34 - 36
for ($i = 0; $i < 50; $i++) {
... lines 38 - 44
$randomTreasure = $treasures[array_rand($treasures)];
$quest->treasure = new QuestTreasure(
... lines 51 - 52
... lines 54 - 55

I love it! Now when we refresh... we see one embedded object and no generated @id field.

Next up: with a custom resource like this, we don't get pagination on our collection resource automatically. Yup, it's returning all 50 items. So let's add that.

Leave a comment!

Login or Register to join the conversation
Alexey-N Avatar
Alexey-N Avatar Alexey-N | posted 12 days ago

Hello Ryan! Thank you for a beautiful tutorials. I've watched v2 tutorial about custom resources. Recently I moved to ApiPlatform v3.
There is a case that works differently in v3 for me (v 3.1.18)

I have an ApiResource with a property as array of some class objects. It's typehinted with phpdoc as you do in this video:

 * @var SomeRow[]
protected array $rows;

In jsonld format response I see that property's type ("@type": "SomeRow",)

But on graphical api/docs page it's shown just as array of strings.
In v2 it was shown here with the type as I remember.
Do you have embedded objects type shown on api/docs page? You haven't shown it in your video
Thank you!


Hey @Alexey-N!

Thank you for a beautiful tutorials


Do you have embedded objects type shown on api/docs page?

Hmm, good question! I just checked the code - for RIGHT at this chapter - when the DailyQuest class still looks like this:

     * @var QuestTreasure[]
    public array $treasures = [];

And I can confirm that the docs DO look correct to me. Down on the schema at the bottom, for Quest.jsonld (Quest is the short name for DailyQuest), under treasures, it shows that it is an array of QuestionTreasure.jsonld, And further up when I try the Get one operation for DailyQuest, the Example Value config DOES show the correct fields.

So, unless I'm not understanding your situation, it seems like you and are are doing exactly the same thing, but we're getting different results....

Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "3.1.x-dev", // 3.1.x-dev
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.1
        "nelmio/cors-bundle": "^2.2", // 2.3.1
        "nesbot/carbon": "^2.64", // 2.69.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.23.1
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.2
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/expression-language": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.2
        "symfony/property-access": "6.3.*", // v6.3.2
        "symfony/property-info": "6.3.*", // v6.3.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-bundle": "6.3.*", // v6.3.3
        "symfony/serializer": "6.3.*", // v6.3.3
        "symfony/stimulus-bundle": "^2.9", // v2.10.0
        "symfony/string": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/ux-react": "^2.6", // v2.10.0
        "symfony/ux-vue": "^2.7", // v2.10.0
        "symfony/validator": "6.3.*", // v6.3.2
        "symfony/webpack-encore-bundle": "^2.0", // v2.0.1
        "symfony/yaml": "6.3.*", // v6.3.3
        "symfonycasts/micro-mapper": "^0.1.0" // v0.1.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.11
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.50.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/browser": "^1.2", // v1.4.0
        "zenstruck/foundry": "^1.26" // v1.35.0