Skip to content

Validation

samuelgfeller edited this page Mar 21, 2024 · 9 revisions

Introduction

Every form or value that users submit should be validated on the server to make sure that its in the expected format and prevent malicious data from being inserted into the database.

CakePHP's lightweight validation library makes validation quite easy and straightforward. It has a flexible ruling system that supports a wide range of validation rules out of the box.

composer require cakephp/validation

Performing validation

Validator instantiation

To begin the validation process, a validator object has to be instantiated.

use Cake\Validation\Validator;
// ... 

$validator = new Validator();

Adding validation rules

Validation rules can be added to the validator object with chained method calls that stand for the wanted rules. The first argument is always the field name, and the others vary on the validation rule of the method.

Require presence

There is an important distinction to be made between if a field key is required to be present in the submitted values or if the field value is required to not be empty.

The requirePresence() method is used to check if a field key is present but not if its empty. An empty string, for instance, is considered as present. Null values are another story.

Null values

If the value is null and the only rule is requirePresence(), it creates an error "This field cannot be left empty".
This is because the Cake validation library automatically sets a rule that field values cannot be null as soon as there is any validation rule set for the field that doesn't start with allowEmpty...().
maxLength(), numeric(), email(), date() etc. have the same underlying non-null rule.

It's important to be aware of this when dealing with optional values, and sadly, this isn't well documented.

Create and update mode

Required values during a creation request may not be required to be present in the validated array during an update when only the updated key-value is sent to the server.
For this distinction, a bool isCreateMode can be added to the requirePresence rule as a second argument:

requirePresence('field_name', $isCreateMode, 'Custom error message')  

If $isCreateMode is false, the field presence is not required.

Note: when submitting a form, radio buttons, checkboxes and potentially other fields are not sent over by the browser if they don't contain a value, so they should never be required to be present if they're optional.

Optional fields

I would recommend making the backend flexible and accepting both null and empty strings for optional values. Especially because there is no out-of-the-box rule for specifically allowing null values.

We have to use the allowEmptyString method which allows for an empty string, null and potentially other values such as false, 0 and "0" but I haven't found documentation on this.

Required fields

Besides requirePresence() that should be used, I recommend using a notEmpty...() rule to clearly define that the field value must not be empty.
Also, it permits setting a custom error message that can be translated.

Custom rules

Custom rules can be added with the add() method. The first parameter is the field name, the second is the name of the custom rule, and the third is an array with the rule options.
The rule key is required and can contain a closure that receives two arguments. The first one is the value that is being validated with this rule and the second one a context array containing, among other things, the array of all values being validated accessible via $context['data'].

$extra = 'Some optional additional value for the closure';
$validator->add('title', 'custom', [
    'rule' => function ($value, $context) use ($extra) {
        // Custom logic that returns true if the validation passes and 
        // false if the error message below should be shown
    },
    'message' => 'The title is not valid'
]);

For more complex custom rules, please check out the documentation for custom rules.

Executing rule only if the previous one has succeeded

From Marking Rules as the Last to Run:

When fields have multiple rules, each validation rule will be run even if the previous one has failed. This allows you to collect as many validation errors as you can in a single pass. If you want to stop execution after a specific rule has failed, you can set the last option to true:

$validator
    ->add('body', [
        'minLength' => [
            'rule' => ['minLength', 10],
            // This rule must succeed before proceeding to the next one
            'last' => true,
            'message' => 'Minimum length 10.',
        ],
        'maxLength' => [
            'rule' => ['maxLength', 250],
            'message' => 'Maximum length 250.',
        ],      
    ]);

Example

This is an example of a simplified validator object that validates the request body of a client creation request.

The error messages are translated with the __() function but this is independent of the validation process. More on translations here.

$validator
    // Require presence indicates that the Field is required in the request body
    // When second parameter "mode" is false, the field presence is not required
    ->requirePresence('first_name', $isCreateMode, __('Field presence is required'))
    // Required field first_name
    ->notEmptyString('first_name', __('Required'))
    ->minLength('first_name', 2, __('Minimum length is 2'))
    ->maxLength('first_name', 100, __('Maximum length is 100'))
    // Optional field last_name
    ->requirePresence('last_name', $isCreateMode, __('Field is required'))
    // allowEmptyString to enable null and empty string because cakephp validation library automatically
    // sets a rule that the field cannot be null when there is another validation rule set for the field.
    ->allowEmptyString('last_name')
    ->minLength('last_name', 2, __('Minimum length is 2'))
    ->maxLength('last_name', 100, __('Maximum length is 100'))
    // E-mail
    ->requirePresence('email', $isCreateMode, __('Field is required'))
    ->allowEmptyString('email')
    ->email('email', false, __('Invalid email'))
    // Birthdate date field with different formats and custom validation rule checking that it's not in the future
    ->requirePresence('birthdate', $isCreateMode, __('Field is required'))
    ->allowEmptyDate('birthdate') // Not required, allow null and empty string
    ->add('birthdate', [
        'date' => [
            'rule' => ['date', ['ymd', 'mdy', 'dmy']],
            'message' => __('Invalid date value'),
            // This rule must succeed before proceeding to the next one
            'last' => true,
        ],
        'validateNotInFuture' => [
            'rule' => function ($value, $context) {
                $today = new \DateTime();
                $birthdate = new \DateTime($value);
                // Check that birthdate is not in the future
                return $birthdate <= $today;
            },
            'message' => __('Cannot be in the future'),
        ],
    ])
    // Sex checkbox options
    // Optional checkbox key may not be sent over by the browser if not set, so second param must be false
    ->requirePresence('sex', false)
    ->allowEmptyString('sex')
    ->inList('sex', ['M', 'F', 'O', ''], 'Invalid option')
    // Client status select options
    ->requirePresence('client_status_id', $isCreateMode, __('Field is required'))
    ->notEmptyString('client_status_id', __('Required'))
    ->numeric('client_status_id', __('Invalid option format'))
    ->add('client_status_id', 'exists', [
        'rule' => function ($value, $context) {
            return $this->clientStatusFinderRepository->clientStatusExists((int)$value);
        },
        'message' => __('Invalid option'),
    ])
;

Executing the validation

To perform the validation and get the result, the validate() method must be called on the validator object with as argument the data to be validated.
The expected data format is an associative array with the field names as keys and the values as values.

It returns an array with the validation errors if it fails or an empty array otherwise.

// Perform validation with the client creation values retrieved from the request body
$errors = $validator->validate($clientCreationValues);

The $errors format is like this:

$errors = [
   'field_name' => [
       'validation_rule_name' => 'Validation error message for that field',
       'other_validation_rule_name' => 'Another validation error message for that field',
   ],
   'first_name' => [
       'minLength' => 'Minimum length is 3',
   ],
   'email' => [
       // Key was not present, requirePresence rule failed
       '_required' => 'This field is required',
   ],
];

Validation error handling

Throwing the exception

Right after the validation is performed a ValidationException is thrown if there are any errors that can be caught in the middleware and transformed into a JSON response.

if ($errors) {
    throw new ValidationException($errors);
}

The ValidationException

The ValidationException is a custom exception that stores the validation error array and may transform it into another format which is expected by the frontend. This has the benefit of adding an abstraction layer between the CakePHP Validation library error output and the frontend.

File: src/Domain/Validation/ValidationException.php

<?php

namespace App\Domain\Validation;

use RuntimeException;

class ValidationException extends RuntimeException
{
    public readonly array $validationErrors;

    public function __construct(array $validationErrors, string $message = 'Validation error')
    {
        parent::__construct($message);

        $this->validationErrors = $this->transformCakephpValidationErrorsToOutputFormat($validationErrors);
    }

    /**
     * Transform the validation error output from the library to array that is used by the frontend.
     * 
     * Removes the rule name as keys and only keeps the error message.
     *
     * @param array $validationErrors The cakephp validation errors
     *
     * @return array the transformed result
     */
    private function transformCakephpValidationErrorsToOutputFormat(array $validationErrors): array
    {
        $validationErrorsForOutput = [];
        foreach ($validationErrors as $fieldName => $fieldErrors) {
            // There may be cases with multiple error messages for a single field
            foreach ($fieldErrors as $infringedRuleName => $infringedRuleMessage) {
                // Removes the rule name as keys and replace with incremented numeric keys
                $validationErrorsForOutput[$fieldName][] = $infringedRuleMessage;
            }
        }

        return $validationErrorsForOutput;
    }
}

Catching the exception and responding with JSON

The exception is caught in the ValidationExceptionMiddleware that transforms the errors into a JSON response with the appropriate status code and response body format.

File: src/Application/Middleware/ValidationExceptionMiddleware.php

<?php

namespace App\Application\Middleware;

use App\Application\Responder\JsonResponder;
use App\Domain\Validation\ValidationException;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final readonly class ValidationExceptionMiddleware implements MiddlewareInterface
{
    public function __construct(
        private ResponseFactoryInterface $responseFactory,
        private JsonResponder $jsonResponder,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        try {
            return $handler->handle($request);
        } catch (ValidationException $validationException) {
            // Create response (status code and header are added later)
            $response = $this->responseFactory->createResponse();

            $responseData = [
                'status' => 'error',
                'message' => $validationException->getMessage(),
                // The error format is already transformed to the format that the frontend expects in the exception.
                'data' => ['errors' => $validationException->validationErrors],
            ];

            return $this->jsonResponder->respondWithJson($response, $responseData, 422);
        }
    }
}

This middleware is added at the end of the middleware stack before the ErrorHandlerMiddleware.

File: config/middleware.php

return function (App $app) {
    // ...
    
    $app->add(\App\Application\Middleware\ValidationExceptionMiddleware::class);  
    $app->add(\App\Application\Middleware\ErrorHandlerMiddleware::class);
};

Testing validation

An example of a test for the validation of a user update request can be found in the
Test Examples.

Clone this wiki locally