Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

DTO Validation & Security

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

Let's talk about validation. When we ->post() to our endpoint, the internal object will now be our UserApi object... which means that's what will be validated. Watch. Send no fields to our POST request... and run that test:

symfony php bin/phpunit --filter=testPostToCreateUser

It fails with a 500 error, and... i bet you can guess why. This says

User::setEmail(): Argument #1 ($email) must be of type string

Coming from our state processor on line 59. Because there are no validation constraints at all on UserApi, the email property remains null. Then, over here on line 59, we try to transfer that null email onto our $entity. It doesn't like that. And even if it did, it would eventually fail in the database because the email is allowed to be null there.

We're missing validation. Fortunately, that's easy to add... once you know that validation will happen on our UserApi class.

Configuration the Operations

But before add some constraints, let's specify the operations... so we'll only have the ones we need: new Get(), new GetCollection(), new Post()... we'll add some config to that in a moment... as well as new Patch() and new Delete().

Back when our User entity was the #[ApiResource], our Post() operation had an extra validationContext option with groups set to Default and postValidation. We did this so that, when the Post() operation happened, it would run all of the normal validators plus any that were in this postValidation group. We'll see where this comes into play in a moment.

Adding the Constraints

Ok, let's add some constraints: $id isn't even writable... we want $email to be #[NotBlank]... and be an #[Email]. We want $username to be #[NotBlank]... then $password is an interesting one. $password should be allowed to be blank if we're doing a PATCH request to edit it... but required on a POST request. To accomplish that, add #[NotBlank] but with a groups option set to postValidation.

This constraint will only be run when we're validating the postValidation group... which means it will only be run for the Post() operation.

Okay, that should be it! Run the test now:

symfony php bin/phpunit --filter=testPostToCreateUser

And... a beautiful 422 status code! That's the validation error, and this is what we want!

UniqueEntity constraint?

By the way, one of the other validation constraints we had before on the User entity was #[UniqueEntity]. That prevents someone from creating two users with the same email or username. I don't have that on UserApi, but we should. The #[UniqueEntity] constraint, unfortunately, only works on entities... so we'd need to create a custom validator to have that on UserApi. We're not going to worry about that right, but I wanted to point it out.

Anyway, back over on the test, re-add the fields. Validation, check!

Adding Security

The next thing we need to re-add - code that used to live on User - is security. Up here on the API level, for the entire resource, let's require is_granted("ROLE_USER").

This means that we need to be logged in to use any of the operations for this resource... by default. Then we overrode that. In the Post(), we definitely can't be logged in yet because we're registering our user. Say, security set to is_granted("PUBLIC_ACCESS") which is a special attribute that will always pass.

Down here for Patch(), we had security('is_granted("ROLE_USER_EDIT")').

In our app, we decided that you need to have this special tole to be able to edit users.

Ok! Let's run all of the tests for User:

symfony php bin/phpunit tests/Functional/UserResourceTest.php

And... oh. Not bad! Three out of four! The failure comes from testTreasuresCannotBeStolen(). That doesn't sound good!

If we check that out... this is a really interesting test where we ->patch() to update a $user, and then try to set the dragonTreasures property to a treasure that is owned by a different user. You can see that this $dragonTreasure is owned by $otherUser... but we're currently updating $user.

What we're attempting to do is steal this $dragonTreasure from $otherUser and make it part of $user. We're asserting that this is a 422 status code... because we previously, we had a custom validator that prevented this.

Well, it still exists - it's this TreasuresAllowedOwnerChangeValidator - but it's not being applied to UserApi and it needs to be updated to work with it. We'll do this later - I just wanted to mention it now.

More importantly, right now, the dragonTreasures property isn't even writable! In UserApi, above $dragonTreasures, we have writable: false. In a bit, we're going to change that so that we can write dragonTreasures again. And when we do that, we'll bring back that validator and make sure this test passes.

Next: If you look at the processor or the provider we created, these classes are pretty generic. They could almost work for UserApi and a future DragonTreasureApi class. The only part that's specific to User is the code that maps to and from the User entity and the UserApi class.

If we could handle that mapping... in some system that lives outside of our provider and processor... we could reuse these classes. Let's take this idea to the next level next.

Leave a comment!

Login or Register to join the conversation
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