Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Input DTO Validation

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 nice thing about input DTOs is that after our data transformer is called and we return the final CheeseListing, that object is validated like normal. We saw this: we submitted empty JSON to create a new CheeseListing and got back errors like "the title should not be blank".

These are coming from the @Assert rules on CheeseListing. CheeseListing is still validated.

But... this isn't the only way that validation can work. One complaint that you'll sometimes here about Symfony's validator is that, for it to work, you need to allow your entity to get into an invalid state. Basically, even though $title should not be blank:

... lines 1 - 63
class CheeseListing
{
... lines 66 - 72
/**
... line 74
* @Assert\NotBlank()
... lines 76 - 80
*/
private $title;
... lines 83 - 182
}

We need to first allow a blank or null value to be set onto the property so that it can then be validated.

This was at the root of a problem we had a minute ago. In CheeseListingInput, we had to add some type-casting here to help us set invalid data onto CheeseListing without causing a PHP error... so that it could then be validated:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 55
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing
{
if (!$cheeseListing) {
$cheeseListing = new CheeseListing((string) $this->title);
}
$cheeseListing->setDescription((string) $this->description);
$cheeseListing->setPrice((int) $this->price);
... lines 64 - 67
}
... lines 69 - 81
}

Moving the Constraints to the Input

Another option is to move the validation from the entity into the input class. If we did that, then when we set the data onto this CheeseListing object, we would know that the data is - in fact - valid.

So let's try this. Undo the typecasting in CheeseListingInput because once we're done, we will know that the data is valid and this won't be necessary:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 55
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing
{
if (!$cheeseListing) {
$cheeseListing = new CheeseListing($this->title);
}
$cheeseListing->setDescription($this->description);
$cheeseListing->setPrice($this->price);
... lines 64 - 67
}
... lines 69 - 81
}

Next in CheeseListing, I'm going to move all of the @Assert constraints onto our input. Copy the two off of $title and move those into CheeseListingInput:

... lines 1 - 11
class CheeseListingInput
{
/**
... lines 15 - 16
* @Assert\NotBlank()
* @Assert\Length(
* min=2,
* max=50,
* maxMessage="Describe your cheese in 50 chars or less"
* )
*/
public $title;
... lines 25 - 94
}

We do need a use statement... but let's worry about that in a minute.

Copy the constraint from $description, move it:

... lines 1 - 11
class CheeseListingInput
{
... lines 14 - 45
/**
* @Assert\NotBlank()
*/
public $description;
... lines 50 - 94
}

Copy the one from $price delete it and... also delete the constraint from $description. We could also choose to keep these validation rules in our entity... which would make sense if we used this class outside of our API and it needed to be validated there.

Paste the constraint above price and... there's one more constraint above owner: @IsValidOwner():

... lines 1 - 11
class CheeseListingInput
{
... lines 14 - 25
/**
... lines 27 - 28
* @Assert\NotBlank()
*/
public $price;
/**
... lines 34 - 35
* @IsValidOwner()
*/
public $owner;
... lines 39 - 94
}

Copy it, delete it, and move it into the input.

That's it! To get the use statements, re-type the end of NotBlank and hit tab to auto-complete it - that added the use statement on top - and do the same for IsValidOwner:

... lines 1 - 6
use App\Validator\IsValidOwner;
... lines 8 - 9
use Symfony\Component\Validator\Constraints as Assert;
... lines 11 - 96

Ok, cool! All of the validation rules live here and we have no constraints in CheeseListing.

Validating the Input Class

But... unfortunately, API Platform does not automatically validate your input DTO objects: it only validates the final API resource object. So we'll need to run validation manually... which is both surprisingly easy and interesting because we'll see how we can trigger API Platform's super nice validation error response manually.

Inside of our data transformer, before we start transferring data, this is where validation should happen. To do that, we need the validator! Add a public function __construct() with a ValidatorInterface argument. But grab the one from ApiPlatform\, not Symfony\. I'll explain why in a second. Call that argument $validator and then I'll go to Alt+Enter and select "Initialize properties" to create that property and set it:

... lines 1 - 6
use ApiPlatform\Core\Validator\ValidatorInterface;
... lines 8 - 10
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
private $validator;
public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}
... lines 19 - 40
}

Down in transform(), $input will be the object that contains the deserialized JSON that we want to validate. Do that with $this->validator->validate($input):

... lines 1 - 10
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 13 - 22
public function transform($input, string $to, array $context = [])
{
$this->validator->validate($input);
... lines 26 - 29
}
... lines 31 - 40
}

That's it! The validator from API platform is a wrapper around Symfony's validator. It wraps it so that it can add a few nice things. Let's check it out.

Hit Shift+Shift, look for Validator.php, include non-project items and open the Validator from API Platform. As I mentioned, this wraps Symfony's validator... which it does so that it can add the validation groups from API Platform's config.

But more importantly, at the bottom, after executing validation, it gets back these "violations" and throws a ValidationException. This is a special exception that you can throw from anywhere to trigger the nice validation error response.

So... let's go see it! At the browser, hit Execute and... yeehaw! A 400 validation error response! But now this is coming from validating our input object. The input must be fully valid before the data will be transferred to our entity.

So if you like this, do it! If you don't, leave your validation constraints on your entity.

Next: it's time for our final topic! Right now, all of our resources use their auto-increment database id as the identifier in the API:

... lines 1 - 42
class User implements UserInterface
{
/**
* @ORM\Id()
* @ORM\GeneratedValue()
* @ORM\Column(type="integer")
*/
private $id;
... lines 51 - 289
}

But in many cases, you can make your life easier - or the life of a JavaScript developer who is using your API easier - by using UUID's instead.

Leave a comment!

5
Login or Register to join the conversation

Ryan, I think you forgot one constraint under CheeseListing: the one called ValidIsPublished, which is applied to the class itself. Ironically, this constraint might be especially troublesome to move not only because we made it to specifically work with CheeseListing objects only, but also because it involves digging into Doctrine internals, which would likely complicate moving the constraint to a non-entity even further. I wonder how you would have solved that. :)

Reply

Hey Adeoweb!

Ah, nice catch! I can't remember now if I did this on purpose... or accident, but I do sometimes miss details like this :).

But let's think. For reference, here is the finished validation logic: https://symfonycasts.com/screencast/api-platform-extending/validator-logic#codeblock-f8b1e9b4ae

I think the key difference is that, if we added this constraint above our input class, the actual CheeseListing entity will, I believe, NOT have been modified yet: only the input class will be modified at this point. And so, believe the logic actually becomes a bit simpler. To get the "original value", instead of using using Doctrine's UnitOfWork, we could just query for the CheeseListing entity itself inside the validator and read its value. Well, to do that, we would need the id, which we don't have. So intsead, I would inject the RequestStack and grab the current request. The CheeseListing object should then be accessible via $request->attributes->get('data'). I believe that would be null if this is a CREATE operation. And so, in that case, you would know that the "original published value" is "false".

Does that help? I could be missing something - I'm just "coding by looking" here, but I think that would do it :).

Cheers!

Reply

"Does it help?" – I don't know, but I guess it would, if it was the problem I was facing. Although injecting stuff like UOW and RequestStack into validators seems somewhat dirty. I'm lucky I don't need to validate cases like this, or I'd be stuck trying to find better ways for a while. :D

Reply
Simo Avatar
Simo Avatar Simo | posted 2 years ago | edited

Hi folks,

there is a small thing which confuses me and I would appriciate if someone could clear thigns for me. In CheeseListingInputDataTransformer:25 we call validator and validate user input but I feel thing might not be the optimal place for it. Shouldn't we do the validation after we merge user-provided input with whatever we potentialy have in DB or I'm missing something. Something like this:

<b>tl;dr;</b>


    public function transform($input, string $to, array $context = [])
    {
     // $input might only be partialy set here, for example if we're in an update
     // $this->validator->validate($input);


    $cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null;


     $cheeseListingInput = $input->createOrUpdateEntity($cheeseListing);


     // At this point we need to be sure that $cheeseListingInput is 100% valid
     $this->validator->validate($cheeseListingInput);


     return $cheeseListingInput;
    }
Reply

Hey @s!

I think I can help with this :). The flow is a bit complex. Here is what's going on (by the time you've finished this chapter):

1) API Platform queries for the actual CheeseListing entity object from the database
2) Thanks to our custom denormalizer - https://symfonycasts.com/screencast/api-platform-extending/input-initializer and the next chapter - we create a CheeseListingInput DTO that is populated with data from the database
3) API Platform then deserializes the JSON from the user onto our CheeseListingInput. This is invisible to us - it happens between our custom normalizer (described above) and our data transformer (next step). This is when, effectively, the user's JSON is merged with the database daata
4) THEN, our data transformer is called. By the first line of transform(), the $input object already represents the merged database and user input data.

So when you say:

Shouldn't we do the validation after we merge user-provided input with whatever we potentialy have in DB

You re 100% correct! That "merge" happens on step 3, which means at step 4 (in our transform() method) it's the perfect place to validate.

As I mentioned, the flow is complex because API Platform does a few things in the background, and we do a few things throughout that process. Hopefully seeing it all listed helps.

Let me know!

Cheers!

Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}
userVoice