backend Oct 09

Go custom if built-in validation constraints let you down.

4 min read –

Since the beginning of computing, the single most error-prone and unreliable component of a system has been something that is not a part of it at all. Talk to anyone who’s worked in technical support and they will tell you that the most common problem that occurs when troubleshooting a computer problem is an operator error.

People make mistakes, especially when trying to communicate their intentions to a piece of complex technology. In addition, there are those who are malicious and intentionally want to enter unexpected data in an application and make it work improperly.

Therefore, it is important that we validate and sanitize the data that users enter into our web applications and Symfony can help us with that.

How does it work?

The Symfony Validation Framework allows us to limit all the possible values that can move through our model. These constraints represent statements about variables in our entity classes that must be held true, otherwise checking the class will result in an error state.

Some examples of built-in constraints which we can use are :

  • $title is not blank
  • $companyName is at least 3 characters long and at most 255 characters long
  • $email is not valid
  • and many more ….

Symfony implements constraints using “assertions”. We apply one or more assertions to properties of our entity classes, after adding data to an instance of the class, we can check whether the state is true or false. If the assertion is false, we have options to change the flow of the application by displaying an error message, redirecting users to another page or proceeding with any other step that is not breaking the application.

Defining constraints

We can define constraints using several different methods :

  • Using PHP annotations which are a default method
  • YAML file
  • XML file
  • PHP file

When it comes to custom constraints, in Locastic we recently had a specific case where we needed to validate user registration based on an invite code, or if admin activated the validation by company domain, it was supposed to be validated by invite code and user’s extracted e-mail domain to which the mail is registered.

Since Symfony built-in constraints can’t always solve every case we come across, we decided to create our own custom constraint. This constraint will check that the user has entered the appropriate invite code previously defined within the company entity, or in case that admin has also activated domain check , we would check if the user entered appropriate invite code and that domain of his user e-mail matches domain of the company he is registering on.

Defining a “Constraint Class”

So the first thing we will do is create a class called CompanyDomainUrlCheck inside the src/Validator/Constraints.

/**
 * @Annotation
 * @Target({"PROPERTY", "METHOD", "ANNOTATION", "CLASS"})
 */
class CompanyDomainUrlCheck extends Constraint
{
    public $fields = [];

    public $message = 'Domain Url is not valid Domain';

    public function getRequiredOptions(): array
    {
        return ['fields'];
    }

    public function validatedBy(): string
    {
        return \get_class($this).'Validator';
    }

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }

    public function getDefaultOption(): string
    {
        return 'fields';
    }
}

Within the constraint class, we defined through annotations that the class can be used over property, over method, as an annotation and over the class.

We extended the class with predefined constraint class from which we subsequently modified methods to suit our needs. Since the constraint will be used over the entity itself, it is necessary to set an array of properties to be checked within the entity and a message to be printed in the event of an error.

Last but not least, we need to declare this constraint as CLASS_CONSTRAINT since we will receive a class object and not properties in the validator.

Defining a “Validator” class

The next thing we will do is write the validator of this Constraint. To do this, we will create a class called CompanyDomainUrlCheckValidator within it where we create the constraint.

Symfony is able to automatically associate the validator associated with each constraint. For this, the validator class will be named as “constraint name + validator”.

class CompanyDomainUrlCheckValidator extends ConstraintValidator
{
    private $companyRepository;

    public function __construct(CompanyRepository $companyRepository)
    {
        $this->companyRepository = $companyRepository;
    }

    public function validate($value, Constraint $constraint)
    {
        /* @var $constraint \App\Validator\CompanyDomainUrlCheck */
        $company = $this->companyRepository->findOneBy([
            'invitationCode' => $value->getUser()->getInvitationCode()
        ]);

        if (!$company instanceof Company) {
            $this->context->buildViolation('app.validator.register.company_check')
                ->atPath('user.invitationCode')
                ->addViolation();
        }

        if (null === $company || !$company->getValidateUserByDomainURL()) {
            return;
        }

        $extractedDomain = DomainExtractor::extractDomainFromEmailAddress($value->getEmail());

        if ($company->getDomainURL() === $extractedDomain) {
            return;
        }

        $this->context->buildViolation('app.validator.register.domain_check')
            ->atPath('user.invitationCode')
            ->addViolation();
    }
}

To perform the validation, the first thing we will need is the CompanyRepository, so we inject it using the Symfony autowire.

Once this is done, we will write the validate method this receives two arguments:

  • The values or object to validate
  • The Constraint that is being validated

Since what we want is to check the company invite code, we need to compare user-entered invite code in the registration form and compare it to the one that is linked to the company in the database. If both of them match, user check will proceed to the next step of validation or in the case of a mismatch the user will get this error message “Please enter your company invite code”.

The next step in the validation will check if admin activated the option to validate the user by company domain URL.

What we did here was to make a class with a function to extract user domain from his e-mail address and compare that extracted domain to company domain.

class DomainExtractor
{
    public static function extractDomainFromEmailAddress(?string $value): ?string
    {
        return ($value) ? substr($value, strpos($value, '@') + 1) : null;
    }
}

If domains match each other, the final step of the validation will pass and the user will be successfully registered and linked to the company based on the invite code or in case of an error the user will get the message “Your domain doesn’t match company domain”.

Registering a validation

After we finish everything in this part of the development, the last step is registering the validator inside services

services:
    app.validator.company_domain_url_check:
        class: App\Validator\CompanyDomainUrlCheckValidator
        arguments:
            - '@app.repository.company'
        tags:
            - { name: validator.constraint_validator }

define which entity validation will be done and then do it inside validator folder in Customer.yaml file.

App\Entity\Customer\Customer:
    constraints:
        - App\Validator\CompanyDomainUrlCheck:
              fields: [email, invitationCode]
              groups: [app_customer_registration]

What we need to do inside Customer.yaml file is to declare what fields we are going to pass for validation and which validation group this check belongs to.

Wrap-up

It may seem like a lot, but the workflow really does make sense, and once you understand the conventions it truly does cut your development time while providing some powerful features. Keep in mind that there is a lot of pre built-in constraints, so first check them out before you approach to make your own.