Starting with Symfony 6.3, such feature landed natively in the framework. Checkout this. https://symfony.com/blog/new-in-symfony-6-3-mapping-request-data-to-typed-objects
Hello 👋
One night I was playing arround with the Symfony app & realized I don't like the act of validating the request body in the controller method itself. I am relatively new to Symfony, so I thought it might be a good thing to try myself & see if I can pull a cleaner way to do this.
Per docs, this is how it looks like:
public function author(ValidatorInterface $validator)
{
$author = new Author();
// ... do something to the $author object
$errors = $validator->validate($author);
if (count($errors) > 0) {
/*
* Uses a __toString method on the $errors variable which is a
* ConstraintViolationList object. This gives us a nice string
* for debugging.
*/
$errorsString = (string) $errors;
return new Response($errorsString);
}
return new Response('The author is valid! Yes!');
}
This looks fine, as well, but I thought it might be nice if I can move this somewhere else.
Ideally, I should be able to just type-hint the request class and maybe call another method to perform validation.
This is how I imagined it. First, let's create the ExampleRequest
class & define fields as just plain PHP properties.
<?php
namespace App\Requests;
class ExampleRequest
{
protected $id;
protected $firstName;
}
Now, we can use PHP 8 attributes (or you can use annotations) to describe validation rules for the fields.
<?php
namespace App\Requests;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
class ExampleRequest
{
#[Type('integer')]
#[NotBlank()]
protected $id;
#[NotBlank([])]
protected $firstName;
}
Perfect, now the fun part. Let's make the following API work:
#[Route('/app', name: 'app')]
public function index(ExampleRequest $request): Response
{
$request->validate();
return $this->json([
'message' => 'Welcome to your new controller!',
'path' => 'src/Controller/AppController.php',
]);
}
We don't have the validate()
method on the ExampleRequest
. Instead of adding it directly in there, I would create a BaseRequest
class that can be re-used for all requests, so individual classes don't have to worry about validation, resolving, etc.
<?php
namespace App\Requests;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
abstract class BaseRequest
{
public function __construct(protected ValidatorInterface $validator)
{
}
public function validate(): ConstraintViolationListInterface
{
return $this->validator->validate($this);
}
}
Don't forget to extend
BaseRequest
inExampleRequest
class.
If you run this, nothing related to validation is going to happen. You will see a regular controller response: Welcome to your new controller!
This is fine. We haven't told the app to stop on validation, break the request, or something else.
Let's see which errors, we got from the validator itself.
#[Route('/app', name: 'app')]
public function index(ExampleRequest $request): Response
{
$errors = $request->validate();
dd($errors);
return $this->json([
'message' => 'Welcome to your new controller!',
'path' => 'src/Controller/AppController.php',
]);
}
Let's fire request using these fields in the body.
Hm, what is happening? We sent id
in the request body, yet it still complains. Well, we never mapped the request body to the ExampleRequest.
Let's do that in the BaseRequest
class.
<?php
namespace App\Requests;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
abstract class BaseRequest
{
public function __construct(protected ValidatorInterface $validator)
{
$this->populate();
}
public function validate(): ConstraintViolationListInterface
{
return $this->validator->validate($this);
}
public function getRequest(): Request
{
return Request::createFromGlobals();
}
protected function populate(): void
{
foreach ($this->getRequest()->toArray() as $property => $value) {
if (property_exists($this, $property)) {
$this->{$property} = $value;
}
}
}
}
What we did here? First of all, we are calling the populate()
method which will just loop through the request body & map the fields to the class properties, if that property exists.
If we fire the same request again, notice how the validation doesn't yell about the id
property anymore.
Let's provide firstName
also and see what is going to happen.
Nice! We passed the validator!
At this point, this is already an improvement since we don't have to call a validator on our own. But, let's take it a step further. Let's make it return the JSON with validation messages if something is wrong.
We want to refactor validate()
method in BaseRequest
.
public function validate()
{
$errors = $this->validator->validate($this);
$messages = ['message' => 'validation_failed', 'errors' => []];
/** @var \Symfony\Component\Validator\ConstraintViolation */
foreach ($errors as $message) {
$messages['errors'][] = [
'property' => $message->getPropertyPath(),
'value' => $message->getInvalidValue(),
'message' => $message->getMessage(),
];
}
if (count($messages['errors']) > 0) {
$response = new JsonResponse($messages);
$response->send();
exit;
}
}
Woah, that's a huge change. It's pretty simple. First, we loop through validation messages & stack them into one massive array which will be the final response.
If we have validation errors at all, we gonna stop the current request & return the JSON response with all messages.
Let's remove the dd()
from the controller & test it again.
#[Route('/app', name: 'app')]
public function index(ExampleRequest $request): Response
{
$request->validate();
return $this->json([
'message' => 'Welcome to your new controller!',
'path' => 'src/Controller/AppController.php',
]);
}
.. now let's fire the request.
Nice! That's cool, we are now automatically returning the validation messages. That's it! Now we can use plain PHP classes with attributes/annotations and validate nicely without having to call a validator each time on our own.
Bonus!
I wanted to remove that $request->validate()
line as well. It's fairly simple!
We can automatically call the validate()
method if, for example, we specify in the request class to automatically validate it.
Let's do it like this.
In BaseRequest
add following method:
protected function autoValidateRequest(): bool
{
return true;
}
.. and now in the constructor of the same BaseRequest
class, we can do the following:
abstract class BaseRequest
{
public function __construct(protected ValidatorInterface $validator)
{
$this->populate();
if ($this->autoValidateRequest()) {
$this->validate();
}
}
// Rest of BaseRequest
By default, we are going to validate the request & display the errors. If you want to disable this per request class, you can just overwrite this method.
<?php
namespace App\Requests;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
class ExampleRequest extends BaseRequest
{
#[Type('integer')]
#[NotBlank()]
protected $id;
#[NotBlank([])]
protected $firstName;
protected function autoValidateRequest(): bool
{
return false;
}
}
Of course, you can adjust it to be false
by default, your pick.
Now we don't need to call $request->validate()
at all.
This is looking nice!
#[Route('/app', name: 'app')]
public function index(ExampleRequest $request): Response
{
return $this->json([
'message' => 'Welcome to your new controller!',
'path' => 'src/Controller/AppController.php',
]);
}
This is BaseRequest after all changes:
<?php
namespace App\Requests;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;
abstract class BaseRequest
{
public function __construct(protected ValidatorInterface $validator)
{
$this->populate();
if ($this->autoValidateRequest()) {
$this->validate();
}
}
public function validate()
{
$errors = $this->validator->validate($this);
$messages = ['message' => 'validation_failed', 'errors' => []];
/** @var \Symfony\Component\Validator\ConstraintViolation */
foreach ($errors as $message) {
$messages['errors'][] = [
'property' => $message->getPropertyPath(),
'value' => $message->getInvalidValue(),
'message' => $message->getMessage(),
];
}
if (count($messages['errors']) > 0) {
$response = new JsonResponse($messages, 201);
$response->send();
exit;
}
}
public function getRequest(): Request
{
return Request::createFromGlobals();
}
protected function populate(): void
{
foreach ($this->getRequest()->toArray() as $property => $value) {
if (property_exists($this, $property)) {
$this->{$property} = $value;
}
}
}
protected function autoValidateRequest(): bool
{
return true;
}
}
.. and this is how to use it:
Step 1: Create request class & define properties. Annotate them with validation rules.
<?php
namespace App\Requests;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
class ExampleRequest
{
#[Type('integer')]
#[NotBlank()]
protected $id;
#[NotBlank([])]
protected $firstName;
}
Step 2: Extend request class with BaseRequest
class ExampleRequest extends BaseRequest
That's it! Happy coding!
Top comments (7)
Thanks for publishing this article!
For other cases similar to this I always used Symfony's "argument value resolvers". See symfonycasts.com/screencast/deep-d... and symfony.com/doc/current/controller... but I'm not sure if they allow to return the detailed validation errors as you shown here.
This definitely looks interesting, thanks for sharing!
I can suggest one change on
protected function populate(): void
What happens is your foreach will be executed for each field inserted in the request, so the user can send a bigger payload and try to deny your server.
A lil' bit change
Then your foreach will runs only for each attribute on the class
wait, what's that? IS IT FORM REQUEST FROM LARAVEL? YES, IT'S EXACTLY THE SAME CONCEPT! 😇😇😇
Man, that
exit;
inBaseRequest
is just bad.How can I fix this? Do check ->validate() in each constructor?
thanks.
Its really similar to Laravel's Request concept.
How can i map a request to a dto after valdiation @beganovich