If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet's talk about validation. Even outside of DTO's, there are two layers of validation. The first asks:
Is a piece of data even settable on a property?
Like, is it a valid data type for that property? And the second asks:
Does it pass validation rules?
Let's start with that first type. Before even thinking about validation, we need to ask: is it even possible for a piece of data to be set onto a property? For example, pretend for a minute that we don't have an input class. In that case, if a price
field is sent in the JSON, it will be set via the setPrice()
method:
... lines 1 - 63 | |
class CheeseListing | |
{ | |
... lines 66 - 147 | |
public function setPrice(int $price): self | |
{ | |
$this->price = $price; | |
return $this; | |
} | |
... lines 154 - 182 | |
} |
And since the int
argument is not nullable, if you tried to send a price
field set to null
that value would not be legal to set on this property.
When this happens, API Platform returns a 400 error. It's not a validation error exactly... but it effectively means the same thing.
Let's see a real example. Inside CheeseListingInput
, all the properties are public... and we're not using PHP 7.4 property types:
... lines 1 - 9 | |
class CheeseListingInput | |
{ | |
/** | |
* @var string | |
* @Groups({"cheese:write", "user:write"}) | |
*/ | |
public $title; | |
/** | |
* @var int | |
* @Groups({"cheese:write", "user:write"}) | |
*/ | |
public $price; | |
/** | |
* @var User | |
* @Groups({"cheese:collection:post"}) | |
*/ | |
public $owner; | |
/** | |
* @var bool | |
* @Groups({"cheese:write"}) | |
*/ | |
public $isPublished = false; | |
public $description; | |
... lines 37 - 81 | |
} |
This means that, technically, we can set each property to any value. But, we also have this @var
, which tells API Platform that the property is supposed to be an int
. And while that documentation wouldn't normally make any difference in how our code behaves, it does cause something to happen in API Platform.
Move over and refresh the documentation. Go to the POST
cheeses endpoint, click "Try it out", and just send a price
field set to, how about, apple
.
Hit Execute. 400 error! It says:
The type of the "price" attribute for class
CheeseListingInput
must be an int, string given.
This is cool. When the deserializer does its work, it first tries to figure out what type the field should be, which we know it does in a number of different ways, like reading Doctrine metadata, setter argument type-hints and even PHPDoc. Then, for scalar types like the int
price, if the field sent in the JSON is not that type, API Platform throws this error.
I love this feature, but I do want to mention two things about it. First, unfortunately, unlike true validation errors, the response doesn't contain a nice list of all of the errors.
For example, if we sent another invalid field, instead of seeing both errors, we would see just one: whichever one happened to be tried first. After fixing that error, then we would see the next one. The response for these type errors also isn't perfect: it doesn't tell you - in a machine-readable way - which field the error comes from. True validation errors do a much better job.
This is something that the API Platform team would like to change so that it can list all of the errors at once... but someone needs to do some work on the Symfony serializer to make it possible. There is a pull request open to do that.
The second thing I want to mention is about the error itself. The stack trace on this error would not be shown on production, but the hydra:description
would. But notice: it mentions our internal class name!
This only happens when using input DTO's and it's a bug that has been fixed and will be released in API Platform 2.5.8. Starting in that version, the class name should not be in the error message.
The big point is: this whole idea of whether or not a piece of data can be set on a property is something you should be aware of. These - "sanity errors" - are not currently as nice as true validation errors, but it's great that API Platform has our back in preventing insane data.
And if you're using an input DTO, you need to be even more aware of this question of:
Can a field be set to a certain value?
Why?
Send a completely empty object to create a CheeseListing
. This should return a 400 error. But which kind? A type error like we just saw? Or a true validation error?
The answer is that this should return a true validation error - saying that some of the fields cannot be blank thanks to the NotBlank
validation constraints. When a field is not sent in the JSON, it's simply not ever set on the object. So not sending a price
field is different than sending a price
field set to null
, which would not be allowed thanks to our @var
PHPDoc.
But when we hit Execute... ah! 500 error! It says:
Argument 1 passed to
setDescription()
must be of type string, null given
This is coming from CheeseListingInput
on line 66:
... lines 1 - 9 | |
class CheeseListingInput | |
{ | |
... lines 12 - 55 | |
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing | |
{ | |
... lines 58 - 61 | |
$cheeseListing->setDescription($this->description); | |
... lines 63 - 67 | |
} | |
... lines 69 - 81 | |
} |
Ah: because the description
field wasn't sent, $this->description
is null... but then setDescription()
on CheeseListing
doesn't allow null:
... lines 1 - 63 | |
class CheeseListing | |
{ | |
... lines 66 - 135 | |
public function setDescription(string $description): self | |
{ | |
... lines 138 - 140 | |
} | |
... lines 142 - 182 | |
} |
The result is a very not cool 500 error.
There are 2 solutions to this. First, you could add validation constraints to your input class to guarantee that certain properties are not null. We're going to talk about that in a few minutes.
Or, you can add some type-casting to avoid the errors. For example: we can cast the description to a (string)
, cast the price to an (int)
And then, up here, we can cast the title to a (string)
:
... 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 | |
} |
That should fix the error. Now, if you're thinking:
Wait a second! I still don't want
description
andtitle
to be empty or theprice
to be zero!
Me either! But check this out: At your browser, hit Execute again. And... surprise! We see true validation errors! It says that title
and description
should not be blank! So yes, even with an input class, validation still happens on our final resource object. Oh, and why is there no error for price
? That field does have a NotBlank
constraint, but it should probably have a GreaterThan
constraint to prevent it from being set to zero.
Anyways, we can see that validation still happens when we're using an input class. How does that work? Can we move the validation to the input class? And should we? That's next.
"Houston: no signs of life"
Start the conversation!
// 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
}
}