> APIs >

Course Overview

Login to bookmark this course

API Platform 3 Part 3: Custom Resources

Become an API power user with our new tutorial on Symfony. Learn state providers, custom fields, data transformation, and more.

  • 1326 students
  • EN/ES Captions
  • EN/ES Script
  • Certificate of Completion

Your Guides

About this course

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
    }
}

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
    }
}

Thanks to part 1 & part 2, we've already built a seriously powerful API, complete with security, custom fields and many more goodies. In this course, we'll take things even further:

  • State Providers & "proper" custom fields
  • Run custom code on a "state" change (e.g. publishing)
  • Custom (non-entity) DTO #[ApiResource] classes
  • The new stateOptions shortcut for DTO's
  • DTO's & state providers (make:state-provider)
  • State processors with DTO's
  • Data transformation with symfonycasts/micro-mapper
  • IGNORED_ATTRIBUTES, security & other tricks to avoid serialization groups
  • Embedded objects (including non-ApiResource objects)
  • Pagination for DTO resources

Woh. If you thought you were dangerous before with API Platform, just wait...

Next courses in the APIs: API Platform 3 section of the APIs Track!

25 Comments

Sort By
Login or Register to join the conversation
Evgeny avatar Evgeny 1 year ago

What about API Platform 3 Part 4: Using graphql to the fullest?

4 | Reply |

Thank you for your suggestion @Evgeny we love to know what other topics you'd like to learn, and we consider them when choosing our next tutorial.
Cheers!

3 | Reply |

I vote for this course too!

| Reply |

Counted! Thanks for your interest in SymfonyCasts tutorials

Cheers!

1 | Reply |
Peter-P avatar Peter-P 1 year ago

HI, could you add an extra chapter about filters? I don't see how they work with custom resources. Is there an easy way to make it work?

3 | Reply |

Hey @Peter-P!

I'm hoping to do an episode 4 at some point, which will include filters. No timeline however! Until then, we do talk about custom filters in the API Platform v2 version of this tutorial - https://symfonycasts.com/screencast/api-platform2-extending - as far as I know, not so much has changed in this area.

I don't see how they work with custom resources. Is there an easy way to make it work?

But to answer this, yes, when you have a custom resource, you probably need to create custom filters. If you're using our stateOptions + entityClass trick, then you can reuse the normal "entity" filters, which we showed.

If you have any questions along the way, I'd be happy to do my best to answer them.

Cheers!

| Reply |
Alessandro-M avatar Alessandro-M 1 year ago

Yes!!

This is the course I've been waiting.

Looking forward to it! :)

1 | Reply |

It's coming soon!

| Reply |
SJ avatar SJ 3 months ago edited

This course leads to an incomplete setup when using a custom resource with a different input and output format (either distinct DTO classes or when using serialization groups). The 'output' property and the 'normalizationContext' on both the resource and operation are ignored, so POST requests will return the input DTO.

I solved this by mapping to the output DTO and returning this object at the end of the process method in the state processor.

...

class DtoToEntityStateProcessor implements ProcessorInterface
{
    ...
    
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
    {
        ...

        return $this->microMapper->map($entity, OutputDto::class, [
            MicroMapperInterface::MAX_DEPTH => 1
        ]);
    }

I am posting this here in case anyone runs into the same issue, as this took me multiple days to track down.

Otherwise, I really appreciated this course. I think having a distinct input and output format are a common use case, so updating the course to include this gotcha could be very helpful for folks.

| Reply |

Hey @SJ

Thank you for sharing your solution. We'll check on it. Cheers!

| Reply |
Kai-G avatar Kai-G 4 months ago

This was a really nice course!

There is just one Issue i'm facing with the DTO and related Objects.
Lets take the DragonTreasureApi file. In that DTO we have the property: $owner which is of type: UserApi so far so good.

Calling the Endpoint (Get / GetCollection) to fetch the DragonTreasures we get a result like this:

{
  "@context": "/api/contexts/Treasure",
  "@id": "/api/treasures/1",
  "@type": "Treasure",
  "name": "collection of novelty mugs with silly sayings",
  "description": "xxxx",
  "value": 847274,
  "coolFactor": 8,
  "owner": "/api/users/3",
  "shortDescription": "Laboriosam officia dolorem aut rerum....",
  "plunderedAtAgo": "1 day ago",
  "isMine": false
}

As we can see, the property owner is currently the IRI, still, so far so good.

Now the Issue. I want to have the property populated with the UserApi Object, instead of only the IRI.
Therefore i've changed the MicroMapperInterface::MAX_DEPTH => 0 to 1 (tried with multiple values, like 10, null etc.) in the DragonTreasureEntityToApiMapper.php File.
In my opinion, the property owner should now be populated with the UserApi Object instead of the IRI.

Dumping the $dto inside the DragonTreasureEntityToApiMapper.php file also shows, that the DTO contains the populated UserApi Object

What am i doing wrong here?

| Reply |

Hey @Kai-G!

Sorry for the slow reply! Here's what's going on: when a field is an #[ApiResource] object, the field will be represented either as an IRI or an embedded object. Which is chosen by the serializer 100% comes down to serialization groups and nothing to do with how your object looks in the end. To do what you want, you'd need to:

A) Serializer DragonTreasureApi in some group - like treasure:read.
B) Add the group treasure:read to at least one property in UserApi (these will become the fields in the embedded object).

The annoying thing is that one of the main benefits of using DTOs is to not need to use groups. But now you suddenly need them again just for this one embedded object. That's why I show a different approach here: https://symfonycasts.com/screencast/api-platform-extending/embedded-object

Let me know if this clarifies!

Cheers!

1 | Reply |

@weaverryan

Thank you for your Answer!
Yes that helped me to understand the Problem.

Just playing along with your Example, i tried the same stuff with GraphQL.
Only thing i did, was adding the dependency "webonyx/graphql-php" ( composer require webonyx/graphql-php )

When i do the following GraphQL Query:

{
  users {
    edges {
      node {
        email
        username
        flameThrowingDistance
        dragonTreasures {
          edges {
            node {
              name
            }
          }
        }
      }
    }
  }
}

I get the following error:

"extensions": {
        "debugMessage": "The class \"App\\Entity\\DragonTreasure\" cannot be retrieved from \"App\\ApiResource\\UserApi\".",
        "file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Common/State/LinksHandlerTrait.php",
        "line": 85,
        "trace": [
          {
            "file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Orm/State/LinksHandlerTrait.php",
            "line": 37,
            "call": "ApiPlatform\\Doctrine\\Orm\\State\\CollectionProvider::getLinks('App\\Entity\\DragonTreasure', instance of ApiPlatform\\Metadata\\GraphQl\\QueryCollection, array(8))"
          },
          {
            "file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Orm/State/CollectionProvider.php",
            "line": 62,
            "call": "ApiPlatform\\Doctrine\\Orm\\State\\CollectionProvider::handleLinks(instance of Doctrine\\ORM\\QueryBuilder, array(1), instance of ApiPlatform\\Doctrine\\Orm\\Util\\QueryNameGenerator, array(8), 'App\\Entity\\DragonTreasure', instance of ApiPlatform\\Metadata\\GraphQl\\QueryCollection)"
          },

Is this a bug in the core? I don't really understand, why it tries to access the DragonTreasure Entity instead of the DragonTreasureApi.

I know this is a bit off topic, but maybe its an easy fix :)

Thanks for your help anyway.

Cheers !

| Reply |

Hey @Kai-G!

Hmm. I don't know anything about the GraphQL implementation in API Platform, so unfortunately, I can't suggest any possible fixes.

Sorry I can't be more helpful!

1 | Reply |
fGuix avatar fGuix 8 months ago

Hello,
Thanks for the Course.
I think there might be a bug in the chapter 36 of this part 3.
In the src/Validator/TreasuresAllowedOwnerChangeValidator.php file, there is a return statement in the loop :

foreach ($value->dragonTreasures as $dragonTreasureApi) {
    assert($dragonTreasureApi instanceof DragonTreasureApi);
    $originalOwnerId = $dragonTreasureApi->owner?->id;
    $newOwnerId = $value->id;
    if (!$originalOwnerId || $originalOwnerId === $newOwnerId) {
        return; // <-- HERE !
    }
    // the owner is being changed
    $this->context->buildViolation($constraint->message)
        ->addViolation();
}

I think this will be problematic when configuring multiple IRIs. Would't that check only the first DragonTreasureApi instance ? then if the first is owned by the user, the others won't be checked ?
As it is far in the course, the problem might come in earlier chapters.

In a completely other subject, I saw you talked about releasing a new episode. It might be good to see how to handle file upload, I followed the official documentation and I had a really difficult time to set it up ;)

| Reply |

Hey @fGuix

That's a good observation. The validator will stop if the DragonTreasureApi object has no owner (a new object has been created), or if the owner changed. But, as you said, there's a tiny issue with this code, it won't check all objects. What I can say is Ryan assumed that all the modified objects belong to the same owner. It can be seen as a bug or not but it can be easily fixed by changing the return statement by continue

Cheers!

| Reply |

Hello @fGuix,

Nice catch, the case looks legit. The best thing to bypass this issue I think will be to change return; to continue;. We will discuss internally how to fix the course properly.

Cheers!

| Reply |
Jeremy avatar Jeremy 10 months ago

This is a really nice way to design APIs, I really love it! This look so much clearer than having tons of serialization groups everywhere, and having calculated field in dedicated classes is so nicer than having them in entities.

But whoa, if you're going to deal with several entities, you better have a nice AI companion to code with you or it'll be a real pain to write all you Api, EntityToApiMapper and *ApiToEntityMapper.
I'd probably gave up while creating these 3 files manually for each Entity, as it is so time consuming and off-putting.

| Reply |

Hey @Jeremy

Yea, I agree, it feels like a lot to do if you want to rely on DTOs. I hope ApiPlatform will add better support to DTOs in the future

Cheers!

| Reply |
Peter-Hoehne avatar Peter-Hoehne 10 months ago

Hi,
as response from POST and PATCH requests we get the serialized resource,
like when i do a GET request. i noticed that changes to the resource that not coming from my POST/PATCH requests are not updated and reflected in the response. an example is an automatically changed property like "updatedAt". instead there is the old value or nothing at all when the value was not set before.
In the follow up requests i see the changes.
Is there an elegant way to ensure that i get the actual state of the resource?
What i tried so far is:
a) in the Dto2Entity Mapper also setting the Dto properties. It works, but at this point i dont have all infos, for example updatedAt coming from an doctrine event.
b) in the processor where you set the id of the generated entity back to the dto. That works but there have to be some sort of callback/hook mechanism for a generic processor.
c) similar to b) not really tested, but is it a good idea to simply call the micromapper and overwrite the dto by mapping the entity back to it?

Thanks for your great tutorials.
Peter

| Reply |

Hey @Peter-Hoehne!

Yea, I think understand the issue and I've had this conversation in the comments on this tutorial a few times (I wish I could find those!).

The answer is (c). Others have suggested it, and I agree. It also removes the need to manually call $data->id = $entity->getId(); to set the entity id back onto the DTO. The micro mapper will take care of that.

So yea, go for C! There could be a small performance impact, but I bet it's small, and it will only happen on those write operations anway.

Cheers!

1 | Reply |
MMilev avatar MMilev 1 year ago edited

Hello, a great course :)

I just have one question. Maybe, I have missed it but we saw how to persist embedded objects with Groups but what about the same case with DTOs?

Is it possible to create nested objects via DTOs? For instance I am trying to persist a new User object with liked Profile object (1:1 in the particular case). I am using the microMapper but now I got an error "400 Nested documents for attribute \"profile\" are not allowed. Use IRIs instead.". If I use serialization Groups I do get to save both objects but it does not retrieve any fields, besides the LD+JSON ones @id, @type.

Everything in all the mappers seems OK.

Example Post Request body:

{
  "email": "test-email@example.com",
  "password": "123456",
  "profile": {
    "firstName": "FirstName",
    "middleName": "MiddleName",
    "lastName": "LastName"
  }
}
| Reply |
MMilev avatar MMilev MMilev 1 year ago edited

I have found the culprit. I use the suggested EntityClassDtoStateProcessor but it does not populate the ID field for all the embedded objects, thus generateIriFromResource throws an exception. In the code bellow the commented line actually makes it work for the discussed case.

EntityClassDtoStateProcessor#process
...

$this->persistProcessor->process($entity, $operation, $uriVariables, $context);
$data->id = $entity->getId();
// $data->profile->id = $entity->getProfile()->getId();

return $data;
| Reply |

Hey @MMilev!

Ah yea, that makes sense. Some people, which I think makes sense, have been using the micromapper after calling $this->persistProcessor->process() to map the $entity back into the DTO. Because the $entity (and in your case the Profile) now have ids, this maps the ids back to the DTO's.

Cheers!

2 | Reply |

Hello, thank you for your answer :) I will try that :)

Best regards :)

| Reply |

Delete comment?

Share this comment

astronaut with balloons in space

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