DEV Community

Cover image for Validating requests in the Symfony app
Benjamin Beganović
Benjamin Beganović

Posted on • Updated on

Validating requests in the Symfony app

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!');
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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',
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}

Enter fullscreen mode Exit fullscreen mode

Don't forget to extend BaseRequest in ExampleRequest 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',
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Let's fire request using these fields in the body.

image.png

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;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

image.png

Let's provide firstName also and see what is going to happen.

image.png

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
    ]);
}
Enter fullscreen mode Exit fullscreen mode

.. now let's fire the request.

image.png

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;
}
Enter fullscreen mode Exit fullscreen mode

.. 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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
        ]);
    }
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

.. 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;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Extend request class with BaseRequest

class ExampleRequest extends BaseRequest
Enter fullscreen mode Exit fullscreen mode

That's it! Happy coding!

Top comments (7)

Collapse
 
javiereguiluz profile image
Javier Eguiluz

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.

Collapse
 
beganovich profile image
Benjamin Beganović

This definitely looks interesting, thanks for sharing!

Collapse
 
vinicinbgs profile image
Vinicius Morais Dutra

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

protected function populate(): void
    {
        $requestFields = $this->getRequest()->toArray();

        foreach (get_object_vars($this) as $attribute => $_) {
            if (isset($requestFields[$attribute])) {
                $this->{$attribute} = $requestFields[$attribute];
            }
        }
Enter fullscreen mode Exit fullscreen mode

Then your foreach will runs only for each attribute on the class

Collapse
 
pjplonka profile image
pjplonka

wait, what's that? IS IT FORM REQUEST FROM LARAVEL? YES, IT'S EXACTLY THE SAME CONCEPT! 😇😇😇

Collapse
 
j4r3kb profile image
Jarosław Brzychcy

Man, that exit; in BaseRequest is just bad.

Collapse
 
xanderevg profile image
XanderEVG

How can I fix this? Do check ->validate() in each constructor?

Collapse
 
emreakay profile image
Emre Akay

thanks.
Its really similar to Laravel's Request concept.

How can i map a request to a dto after valdiation @beganovich