Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Leveraging the Core Processor

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

Look at us go! In our state processor, we have successfully transformed the UserApi into a User entity. So let's save it! We could inject the entity manager, persist and flush... and call it a day. But I'd rather offload that work to the core PersistProcessor. Search for that file and open it.

It does the simple persisting and flushing... but it also has some pretty complex logic for PUT operations. We're not really using those, but the point is: better to reuse this class than try to roll our own logic.

Calling the Core PersistProcessor

How we do that should be familiar by this point. Add a private ProcessorInterface $persistProcessor... and so Symfony knows precisely which service we want, include the #[Autowire()] attribute, with service set to PersistProcessor (in this case, there's only one to choose from) ::class.

Very nice! Below, save with $this->persistProcessor->process() passing $entity, $operation, $uriVariables, and $context... which are all the same arguments we have up here.

Oh, and like before, when we generated this class, it generated process() with a void return type. That's not exactly correct. You don't have to return anything from state processors, but you can. And whatever you do return - in this case, we'll return $data - will ultimately become the "thing" that is serialized and returned back to the user. If you don't return anything, it will use $data.

Setting the id onto the DTO

Ok, I think this should work (Famous last words...).

symfony php bin/phpunit --filter=testPostToCreateUser

And... it bombs. We're still getting a 400 error, and it's still Unable to generate an IRI for the item.

So... what's going on? We map the UserApi to a new User object and save the new User... which causes Doctrine to assign the new id to that entity object. But we never take that new id and put it back onto our UserApi.

To fix this, after saving, add $data->id = $entity->getId().

And if we try it now...

symfony php bin/phpunit --filter=testPostToCreateUser

it still fails... but we got further this time! The response looks good. It returned a 201 status code with the new user info. It's failing on the part of the test where it tries to use the password to log in. That's because our password is currently set to... TODO. We'll fix that in a minute.

Handling the Delete Operation

But first, when we set the processor on the top level #[ApiResource], this became the processor for all operations: POST, PUT, PATCH, and DELETE. POST, PUT, and PATCH are all pretty much the same: save the object to the database. But DELETE is different: we're not saving, we're removing.

To handle that, check if ($operation instanceof DeleteOperationInterface). Like with saving, deleting isn't hard... but it's still better to offload this work to the core Doctrine remove processor. So, up here, copy the argument... and inject another processor: RemoveProcessor... and rename this to $removeProcessor.

Back down here, say $this->removeProcessor->process() and pass $entity, $operation, $uriVariables, and $context just like the other processor.

A key thing to note is that we're going to return null. In the case of a DELETE operation, we don't return anything in the response... which we accomplish by returning null from here. I don't have a test set up for this, but we'll take a leap of faith and assume it works. Ship it!

Hashing the Password

Just one more problem to tackle: hashing the plain password. We've done this before, so no biggie. Before we do too much here, open UserApi... and add a public ?string $password = null... with a comment. This will always hold null or the "plaintext" password if the user sends one. We're never going to need to handle the hashed password in our API, so we don't need any space for that... which is nice!

Back in the processor, if ($dto->password), then we know we need to hash that and set it on the user. If a new user is being created, this will always be set... but when updating a user, we'll make this field optional. If it's not set, do nothing so the user's current password stays.

To do the hashing, on top, add one more argument: private UserPasswordHasherInterface $userPasswordHasher. Then back below, $entity->setPassword() set to $this->userPasswordHasher->hashPassword(), passing $entity (the User object) and the plain password: $dto->password.

Phew. Let's try the test again. And... it fails... with

The annotation "@The" in property UserApi::$password was never imported.

So... that's me tripping on my keyboard and adding an extra @. Remove that... then try again:

symfony php bin/phpunit --filter=testPostToCreateUser

It passes! Which means it fully-logged in using that password! Though, uh oh, look at the dumped JSON response: this is after we POST to create the user. In the JSON response, it includes the plaintext password property that the user just set. Whoops!

The Flow of a Write Request

Let's break this down. Our state provider is used for all GET operations as well as the PATCH operation. And notice, we are not setting the password ever. We don't want to return that field in the JSON, so we're, correctly, not mapping it from our entity to our DTO. That's good!

But the POST operation is the one situation where the provider is never called. This data is deserialized directly into a new UserApi object and that's passed to our processor. This means that our DTO does have the plain password set on it... And, ultimately, that DTO object is what is serialized and sent back to the user.

This is a long way of saying that, in UserApi, this password is meant to be a write-only field. The user should never be able to read this. Next: let's talk about how we can do customizations like this inside of UserApi, while avoiding the complexity of serialization groups.

Leave a comment!

Login or Register to join the conversation

Hello Rayan

I have created a "UserApi mapper" to map the Doctrine User entity and a "BlogApi mapper" to map the Doctrine Blog entity. I try to create a sub-resource of the type /api/users/{id}/blogs, with itemUriTemplate: '/users/{userId}/blogs/{blogId}'. However, I get an error :

Example of how to reproduce a bug :


namespace App\ApiResource;

    shortName: 'users',
    operations: [
        new Get(
            uriTemplate: '/users/{id}'
    provider: UserProvider::class,
    stateOptions: new Options(entityClass: User::class),
class UserApi

    #[ApiProperty(readable: false, writable: false, identifier: true)]
    public ?int $id =null;
    public ?string $username=null;
    /** @var BlogApi[]  */
    public array $blogs = [];
    shortName: 'blogs',
    operations: [
        new Get(
            uriTemplate: '/users/{userId}/blogs/{blogId}',
            uriVariables: [
                'userId' => new Link(
                    toProperty: 'owner',
                    fromClass: User::class
                'blogId' => new Link(
                    fromClass: Blog::class

        new GetCollection(
            uriTemplate: '/users/{userId}/blogs',
            uriVariables: [
                'userId' => new Link(
                    toProperty: 'owner',
                    fromClass: User::class,
            denormalizationContext: [ 'force_resource_class' => Blog::class],
            itemUriTemplate: '/users/{userId}/blogs/{blogId}',
    paginationItemsPerPage: 5,
    provider: BlogProvider::class,
    processor: BlogProcessor::class,
    stateOptions: new Options(entityClass: Blog::class)


class BlogApi
    #[ApiProperty(readable: false, writable: false, identifier: true)]
    public ?int $id = null;
    public ?UserApi $owner = null;

    "@context": "/api/contexts/Error",
    "@type": "hydra:Error",
    "hydra:title": "An error occurred",
    "hydra:description": "Unable to generate an IRI for the item of type \"App\\ApiResource\\BlogApi\"",

Hey @mcdzign!

Hmm. That error - "Unable to generate an IRI" - can happen for several reasons, the simplest being that the BlogApi object (for some reason) has null for its id. But there are other things that can cause this. When I see this error, I open the profiler for that request (you can go to /_profiler to find the most recent requests) and then go to the Exceptions tab. This error will be on top, but usually there is a nested error further below that may give more granular details about what's going on. Do you see a 2nd exception message at the bottom?


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