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!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.0 || ^8.0",
        "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.9.10
        "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
    }
}