My FeedDiscussionsHeadless CMS
New
Sign in
Log inSign up
Learn more about Hashnode Headless CMSHashnode Headless CMS
Collaborate seamlessly with Hashnode Headless CMSΒ for Enterprise.
Upgrade ✨Learn more
Validating requests in the Symfony app

Validating requests in the Symfony app

Benjamin Beganović's photo
Benjamin Beganović
Β·Oct 16, 2021Β·

6 min read

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 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',
    ]);
}

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

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

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.

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

.. 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!