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 SubscribeThere are a bunch of different ways for the users of our API to mess things up, like bad JSON or doing silly things like passing a negative number for the value
field. This is dragon gold, not dragon debt!
This chapter is all about handling these bad things in a graceful way. Try the POST endpoint. Let's send some invalid JSON. Hit Execute. Awesome! A 400
error! That's what we want. 400 - or any status code that starts with 4 - means that the client - the user of the API - made a mistake. 400 specifically means "bad request".
In the response, the type is hydra:error
and it says: An error occurred
and Syntax Error
. Oh, and this trace
only shows in the debug environment: it won't be shown on production.
So this is pretty sweet! Invalid JSON is handled out-of-the-box.
Let's try something different, like sending empty JSON. This gives us the dreaded 500 error. Boo. Internally, API platform creates a DragonTreasure
object... but doesn't set any data on it. And then it explodes when it hits the database because some of the columns are null
.
And, we expected this! We're missing validation. Adding validation to our API is exactly like adding validation anywhere in Symfony. For example, find the name
property. We need name
to be required. So, add the NotBlank
constraint, and hit tab. Oh, but I'm going to go find the NotBlank
use
statement... and change this to Assert
. That's optional... but it's the way the cool kids tend do it in Symfony. Now say Assert\NotBlank
:
... lines 1 - 19 | |
use Symfony\Component\Validator\Constraints as Assert; | |
... lines 21 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 61 | |
... line 63 | |
private ?string $name = null; | |
... lines 65 - 188 | |
} |
Below, add one more: Length
. Let's say that the name should be at least two characters, max
50 characters... and add a maxMessage
: Describe your loot in 50 chars or less
:
... lines 1 - 19 | |
use Symfony\Component\Validator\Constraints as Assert; | |
... lines 21 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 61 | |
min: 2, max: 50, maxMessage: 'Describe your loot in 50 chars or less') ( | |
private ?string $name = null; | |
... lines 65 - 188 | |
} |
Good start! Let's try it again. Take that same empty JSON, hit Execute, and yes! A 422 response! This is a really common response code that usually means there was a validation error. And behold! The @type
is ConstraintViolationList
. This is a special JSON-LD type added by API Platform. Earlier, we saw this documented in the JSON-LD
documentation.
Watch: go to /api/docs.jsonld
and search for a ConstraintViolation
. There it is! API Platform adds two classes - ConstraintViolation
and ConstraintViolationList
to describe how validation errors will look. A ConstraintViolationList
is basically just a collection of ConstraintViolations
... and then it describes what the ConstraintViolation
properties are.
We can see these over here: we have a violations
property with propertyPath
and then the message
below.
Ok! Let's sneak in a few more constraints. Add NotBlank
above description
... and GreaterThanOrEqual
to 0
above value
to avoid negatives. Finally, for coolFactor
use GreaterThanOrEqual
to 0 and also LessThanOrEqual
to 10. So something between 0 and 10:
... lines 1 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 68 | |
private ?string $description = null; | |
... lines 71 - 77 | |
0) ( | |
private ?int $value = null; | |
... lines 80 - 82 | |
0) ( | |
10) ( | |
private ?int $coolFactor = null; | |
... lines 86 - 192 | |
} |
And while we're here, we don't need to do this, but I'm going to initialize $value
to 0 and $coolFactor
to 0. This makes both of those not required in the API: if the user doesn't send them, they'll default to 0:
... lines 1 - 51 | |
class DragonTreasure | |
{ | |
... lines 54 - 68 | |
private ?string $description = null; | |
... lines 71 - 77 | |
0) ( | |
private ?int $value = 0; | |
... lines 80 - 82 | |
0) ( | |
10) ( | |
private ?int $coolFactor = 0; | |
... lines 86 - 192 | |
} |
Ok, go back and try that same endpoint. Look at that beautiful validation! Also try setting coolFactor
to 11
. Yup! No treasure is that cool... well, unless it's a giant plate of nachos.
Ok, there's one last way that a user can send bad stuff: by passing the wrong type. So coolFactor: 11
will fail our validation rules. But what if we pass a string
instead? Yikes! Hit Execute. Okay: a 400
status code, that's good. Though, it's not a validation error, it has a different type. But it does tell the user what happened:
the type of the
coolFactor
attribute must beint
,string
given.
Good enough! This is thanks to the setCoolFactor()
method. The system sees the int
type and so it rejects the string with this error.
So the only thing that we need to worry about in our app is writing good code that properly uses type
and adding validation constraints: the safety net that catches business rule violations... like value
should be greater than 0 or description
is required. API Platform handles the rest.
Next: our API only has one resource: DragonTreasure
. Let's add a second resource - a User
resource - so that we can link which user owns which treasure in the API.
Yo @Huy!
I think I understand :). In this case, coolFactor
is private property, so we're setting it via the setCoolFactor()
method. Because it has an int
type-hint - setCoolFactor(int $coolFactor)
- if the user sends apple
for this, it will fail during denormalization (i.e. it will fail to even SET the value). This gives us the 400 error. It never even GETS to the validation step.
If you want to, instead, enforce this via validation, you need to allow the coolFactor
property to be set to a string... which you CAN do, but it's kind of a bummer to allow such a weird value to even get set into your entity (even if you will validate it). So, you would need to:
A) Remove the ?int
property type from the coolFactor
property
B) Remove the int
type from the setCoolFactor()
method
That will allow the serializer to set the value... and THEN validation will happen. Let me know if this helps!
Cheers!
Hi @weaverryan, thank you, it works but when I pass the correct coolFactor in the request body, it returns 400 error like below
The request:
{
"name": "test",
"value": 12,
"coolFactor": 2,
"isPublished": 123,
"description": "test"
}
The response:
{
"title": "An error occurred",
"detail": "The type of the \"coolFactor\" attribute must be \"string\", \"integer\" given.",
"status": 400,
}
Then I try to validate the $isPublished with the annotation #[Assert\Type('bool')] to make sure the isPublished must be boolean type (not number, string...). then I send the request with $isPublished = 123, although I tried the approach you suggested above like removing the ?bool int property type and bool type from setIsPublished, the response status is still 400 instead of 422.
Sample request:
POST api/treasures
{
"name": "test",
"value": 12,
"coolFactor": "test",
"isPublished": 123,
"description": "test"
}
Response 400:
{
"title": "An error occurred",
"detail": "The type of the \"isPublished\" attribute must be \"string\", \"integer\" given.",
"status": 400,
}
This is my DragonTreasure entity
<?php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Traits\Timestamp;
use App\Repository\DragonTreasureRepository;
use Carbon\Carbon;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Contracts\Service\Attribute\Required;
use function Symfony\Component\String\u;
#[ApiResource(
shortName: 'Treasure',
description: 'Rare and valuable resources',
operations: [
new Get(normalizationContext: ['groups' => ['treasure:read', 'treasure:item:read']]),
new GetCollection(),
new Post(security: 'is_granted("ROLE_TREASURE_CREATE")',),
new Put( security: 'is_granted("ROLE_TREASURE_EDIT")',),
new Patch( security: 'is_granted("ROLE_TREASURE_EDIT")',),
new Delete( security: 'is_granted("ROLE_ADMIN")',)
],
formats: [
'jsonld',
'json',
'html',
'jsonhal',
'csv' => 'text/csv'
],
normalizationContext: [
'groups' => ['treasure:read']
],
denormalizationContext: [
'groups' => ['treasure:write']
],
paginationItemsPerPage: 10
)]
#[ApiResource(
uriTemplate: 'users/{user_id}/treasures.{_format}',
shortName: 'Treasure',
operations: [
new GetCollection()
],
uriVariables: [
'user_id' => new Link(
fromProperty: 'dragonTreasures', fromClass: User::class,
// toProperty: 'owner'
)
],
normalizationContext: [
'groups' => ['treasure:read']
],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: DragonTreasureRepository::class)]
#[ApiFilter(SearchFilter::class, properties: ['owner.name' => 'partial'])]
class DragonTreasure
{
use Timestamp;
/**
* @var \DateTime|null
* @Timestampable(on="create")
* @Column(type="datetime")
*/
#[Timestampable(on: 'create')]
#[Column(type: Types::DATETIME_MUTABLE)]
#[Groups(['treasure:read'])]
protected $createdAt;
/**
* @var \DateTime|null
* @Gedmo\Timestampable(on="update")
* @ORM\Column(type="datetime")
*/
#[Timestampable(on: 'update')]
#[Column(type: Types::DATETIME_MUTABLE)]
#[Groups(['treasure:read'])]
protected $updatedAt;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['treasure:read'])]
private ?int $id = null;
#[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
#[ORM\Column(length: 255)]
#[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
#[NotBlank]
#[NotNull]
private ?string $name = null;
#[Groups(['treasure:read', 'users:item:read'])]
#[ORM\Column(type: Types::TEXT)]
#[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
private ?string $description = null;
/**
* Value of the treasure
*/
#[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
#[ORM\Column]
#[ApiFilter(RangeFilter::class)]
#[GreaterThanOrEqual(0)]
#[NotBlank]
#[NotNull]
#[Type('integer')]
private ?int $value = 0;
#[Groups(['treasure:read', 'treasure:write'])]
#[ORM\Column]
#[GreaterThanOrEqual(0)]
#[LessThanOrEqual(10)]
#[NotBlank]
#[Type('numeric')]
private $coolFactor;
#[Groups(['treasure:read', 'treasure:write'])]
#[ORM\Column]
#[Type('bool')]
private $isPublished = false;
#[Groups(['treasure:read'])]
#[ORM\ManyToOne(inversedBy: 'dragonTreasures')]
#[ORM\JoinColumn(name: 'owner_id', onDelete: 'CASCADE')]
#[ApiFilter(SearchFilter::class, 'exact')]
private ?User $owner = null;
public function __construct(string $name = null)
{
$this->name = $name;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
#[Groups(['treasure:read'])]
public function getShortDescription(): ?string
{
return u($this->description)->truncate(10, '...');
}
public function getDescription(): ?string
{
return $this->description;
}
#[Groups(['treasure:read'])]
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
#[Groups(['treasure:read'])]
public function getCreatedAtAgo(): ?string
{
return Carbon::parse($this->createdAt)->diffForHumans();
}
#[Groups(['treasure:write'])]
#[SerializedName('description')]
public function setTextDescription(string $description): static
{
$this->description = nl2br($description);
return $this;
}
public function getValue(): ?int
{
return $this->value;
}
public function setValue(int $value): static
{
$this->value = $value;
return $this;
}
public function getCoolFactor()
{
return $this->coolFactor;
}
public function setCoolFactor($coolFactor): static
{
$this->coolFactor = $coolFactor;
return $this;
}
public function getIsPublished()
{
return $this->isPublished;
}
public function setIsPublished($isPublished): static
{
$this->isPublished = $isPublished;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): static
{
$this->owner = $owner;
return $this;
}
}
Could you suggest any ways to resolve this issue?
Hey @Huy!
Hmm. I have a feeling this is being caused by Doctrine. So when deserialization happens, it uses Symfony's property-info component to get the "type" of a property - e.g. coolFactor
in this case. To get the type, it looks for a true PHP type on the property, a type on the argument in setCoolFactor()
and it even looks at your PHPDoc - e.g. for an @var
above the property. If It finds a type & the type send by the user doesn't match, it'll throw that 400 error.
However, you have NONE of that. But I still think that SOMETHING is "guessing" the type of your field. The property-info component also gets metadata from Doctrine. And, because you don't have a type
in your column, this is a string field. And so, this might be telling the property info system that this is a string
field. Still, it's a bit annoying, since if you send the integer 40
, PHP can very easily cast that to the string '40'
.
Anyway, the only way that I know of around this problem - which might actually be perfect for you - is to set ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false
in your context. So you could add a denormalizationContext
option to your #[ApiResource]
and set that there. Just be aware that the serializer will now ALWAYS set the value onto your properties, even if it's something crazy (e.g. the user could, I think, even send an array to the coolFactor
property). If your code explodes when that is set (before validation) that would trigger a 500 error.
Anyway, with this option, you could actually, I think, re-add your property types, etc.
Cheers!
So, I'm a bit stumped as to WHY the serializer still thinks that coolFactor
Hi @weaverryan
I followed your suggestion but it still didn't work. When I pass the coolFactor as an integer, the response status still is 400.
Moreover, I also can not find the ObjectNormalizer class, it seems the class AbstractObjectNormalizer instead
The DragonTreasure entity:
<?php
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\JsonLd\Serializer\ObjectNormalizer;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\Entity\Traits\Timestamp;
use App\Repository\DragonTreasureRepository;
use App\Validator\IsValidOwner;
use Carbon\Carbon;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Column;
use Gedmo\Mapping\Annotation as Gedmo;
use Gedmo\Mapping\Annotation\Timestampable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\LessThanOrEqual;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\NotNull;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Contracts\Service\Attribute\Required;
use function Symfony\Component\String\u;
#[ApiResource(
shortName: 'Treasure',
description: 'Rare and valuable resources',
operations: [
new Get(normalizationContext: ['groups' => ['treasure:read', 'treasure:item:read']]),
new GetCollection(),
new Post(security: 'is_granted("ROLE_TREASURE_CREATE")',),
// new Put( security: 'is_granted("ROLE_TREASURE_EDIT")',),
new Patch(
security: 'is_granted("EDIT", object)',
// securityPostDenormalize: 'is_granted("EDIT", object)'
),
new Delete( security: 'is_granted("ROLE_ADMIN")',)
],
formats: [
'jsonld',
'json',
'html',
'jsonhal',
'csv' => 'text/csv'
],
normalizationContext: [
'groups' => ['treasure:read']
],
denormalizationContext: [
'groups' => ['treasure:write'],
AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false
],
paginationItemsPerPage: 10
)]
#[ApiResource(
uriTemplate: 'users/{user_id}/treasures.{_format}',
shortName: 'Treasure',
operations: [
new GetCollection()
],
uriVariables: [
'user_id' => new Link(
fromProperty: 'dragonTreasures', fromClass: User::class,
// toProperty: 'owner'
)
],
normalizationContext: [
'groups' => ['treasure:read']
],
denormalizationContext: [
AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false
],
)]
#[ApiFilter(BooleanFilter::class, properties: ['isPublished'])]
#[ApiFilter(PropertyFilter::class)]
#[ORM\Entity(repositoryClass: DragonTreasureRepository::class)]
#[ApiFilter(SearchFilter::class, properties: ['owner.name' => 'partial'])]
class DragonTreasure
{
use Timestamp;
/**
* @var \DateTime|null
* @Timestampable(on="create")
* @Column(type="datetime")
*/
#[Timestampable(on: 'create')]
#[Column(type: Types::DATETIME_MUTABLE)]
#[Groups(['treasure:read'])]
protected $createdAt;
/**
* @var \DateTime|null
* @Gedmo\Timestampable(on="update")
* @ORM\Column(type="datetime")
*/
#[Timestampable(on: 'update')]
#[Column(type: Types::DATETIME_MUTABLE)]
#[Groups(['treasure:read'])]
protected $updatedAt;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['treasure:read'])]
private ?int $id = null;
#[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
#[ORM\Column(length: 255)]
#[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
#[NotBlank]
#[NotNull]
private ?string $name = null;
#[Groups(['treasure:read', 'users:item:read'])]
#[ORM\Column(type: Types::TEXT)]
#[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IPARTIAL)]
private ?string $description = null;
/**
* Value of the treasure
*/
#[Groups(['treasure:read', 'treasure:write', 'users:item:read'])]
#[ORM\Column]
#[ApiFilter(RangeFilter::class)]
#[GreaterThanOrEqual(0)]
#[NotBlank]
#[NotNull]
#[Type('integer')]
private ?int $value = 0;
#[Groups(['treasure:read', 'treasure:write'])]
#[ORM\Column]
#[GreaterThanOrEqual(0)]
#[LessThanOrEqual(10)]
#[NotBlank]
#[Type('numeric')]
private ?int $coolFactor;
#[Groups(['admin:read', 'admin:write', 'owner:read'])]
#[ORM\Column]
#[Type('bool')]
// #[ApiProperty(readable: false)]
// #[ApiProperty(security: 'is_granted("EDIT", object)')] //To indicate this property should return when user can edit via DragonTreasureVoter
private ?bool $isPublished = false;
#[Groups(['treasure:read', 'treasure:write'])]
#[ORM\ManyToOne(inversedBy: 'dragonTreasures')]
#[ORM\JoinColumn(name: 'owner_id', onDelete: 'CASCADE')]
#[ApiFilter(SearchFilter::class, 'exact')]
#[IsValidOwner]
private ?User $owner = null;
public function __construct(string $name = null)
{
$this->name = $name;
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
#[Groups(['treasure:read'])]
public function getShortDescription(): ?string
{
return u($this->description)->truncate(10, '...');
}
#[Groups(['treasure:read'])]
public function getIsRich(): ?string
{
return true;
}
public function getDescription(): ?string
{
return $this->description;
}
#[Groups(['treasure:read'])]
public function setDescription(string $description): static
{
$this->description = $description;
return $this;
}
#[Groups(['treasure:read'])]
public function getCreatedAtAgo(): ?string
{
return Carbon::parse($this->createdAt)->diffForHumans();
}
#[Groups(['treasure:write'])]
#[SerializedName('description')]
public function setTextDescription(string $description): static
{
$this->description = nl2br($description);
return $this;
}
public function getValue()
{
return $this->value;
}
public function setValue($value): static
{
$this->value = $value;
return $this;
}
public function getCoolFactor(): ?int
{
return $this->coolFactor;
}
public function setCoolFactor(?int $coolFactor): static
{
$this->coolFactor = $coolFactor;
return $this;
}
public function getIsPublished()
{
return $this->isPublished;
}
public function setIsPublished(?bool $isPublished): static
{
$this->isPublished = $isPublished;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): static
{
$this->owner = $owner;
return $this;
}
}
The sample request payload:
PATCH api/treasures/:id
{
// "name": "<string>",
// "value": "sd"
"coolFactor":2
// "isPublished": "<boolean>",
// "description": "<string>",
// "owner": "api/users/1"
}
Hey @Huy!
Hmm. First, yea, AbstractObjectNormalizer
is correct - ObjectNormalizer
should work too (it's a sub-class), but the constant is on AbstractObjectNormalizer
- so that's better anyway.
So you're still getting a response that looks like this?
{
"title": "An error occurred",
"detail": "The type of the \"coolFactor\" attribute must be \"string\", \"integer\" given.",
"status": 400,
}
I don't know if it's related, but I did misspeak in my previous message. You will still need to NOT have an argument type on setCoolFactor()
and you still need to NOT have a property type on the $coolFactor
property. This is because we DO need to allow the property to be set with a string
type (for example) so that we can THEN run validation on that.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/core": "^3.0", // v3.0.8
"doctrine/annotations": "^1.0", // 1.14.2
"doctrine/doctrine-bundle": "^2.8", // 2.8.0
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.14", // 2.14.0
"nelmio/cors-bundle": "^2.2", // 2.2.0
"nesbot/carbon": "^2.64", // 2.64.1
"phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
"phpstan/phpdoc-parser": "^1.15", // 1.15.3
"symfony/asset": "6.2.*", // v6.2.0
"symfony/console": "6.2.*", // v6.2.3
"symfony/dotenv": "6.2.*", // v6.2.0
"symfony/expression-language": "6.2.*", // v6.2.2
"symfony/flex": "^2", // v2.2.4
"symfony/framework-bundle": "6.2.*", // v6.2.3
"symfony/property-access": "6.2.*", // v6.2.3
"symfony/property-info": "6.2.*", // v6.2.3
"symfony/runtime": "6.2.*", // v6.2.0
"symfony/security-bundle": "6.2.*", // v6.2.3
"symfony/serializer": "6.2.*", // v6.2.3
"symfony/twig-bundle": "6.2.*", // v6.2.3
"symfony/ux-react": "^2.6", // v2.6.1
"symfony/validator": "6.2.*", // v6.2.3
"symfony/webpack-encore-bundle": "^1.16", // v1.16.0
"symfony/yaml": "6.2.*" // v6.2.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
"symfony/debug-bundle": "6.2.*", // v6.2.1
"symfony/maker-bundle": "^1.48", // v1.48.0
"symfony/monolog-bundle": "^3.0", // v3.8.0
"symfony/stopwatch": "6.2.*", // v6.2.0
"symfony/web-profiler-bundle": "6.2.*", // v6.2.4
"zenstruck/foundry": "^1.26" // v1.26.0
}
}
hi, regarding coolFactor, is there any way we can validate that the type must be numeric so the response will have status 422 with the error message instead of 400?
I tried the Type validation with numeric type but it doesn't work