Skip to content

Validation

The framework integrates Symfony Validator, configured with an AttributeLoader so all constraints are declared as PHP attributes on class properties. The validator is wired to the DI container through a ContainerConstraintValidatorFactory, which means constraint validators that need services injected must be registered in the DI container.


Using the validator

Inject Symfony\Component\Validator\Validator\ValidatorInterface and call validate().

use Symfony\Component\Validator\Validator\ValidatorInterface;

$violations = $this->validator->validate($input);
if ($violations->count()) {
    // handle violations
}

validate() returns a ConstraintViolationListInterface. Each violation exposes getMessage(), getPropertyPath(), and getCode(). When validating controller input, the JsonInputArgumentProvider and RawInputArgumentProvider call the validator automatically and throw InputException on failure — you do not need to validate manually in those controllers.


Declaring constraints on a class

Annotate properties with Symfony constraint attributes. All standard Symfony constraints are available.

use Symfony\Component\Validator\Constraints as Assert;
use Amarant\Framework\Validator\Constraints\RepeatedField;

final class ChangePasswordInput implements JsonSerializableInterface
{
    use JsonSerializeTrait;

    #[Json]
    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public readonly string $password;

    #[Json('password_confirm')]
    #[Assert\NotBlank]
    #[RepeatedField(targetPath: 'password')]
    public readonly string $passwordConfirm;
}

Built-in framework constraints

The following custom constraints ship with the framework.

RepeatedField

Validates that a property's value matches another property on the same object. Useful for password confirmation fields.

#[RepeatedField(targetPath: 'password')]
public readonly string $passwordConfirm;

targetPath is the property name of the reference field on the root object.

DifferentFromField

Validates that a property's value is different from another property on the same object.

#[DifferentFromField(targetField: 'currentPassword')]
public readonly string $newPassword;

Optional targetFieldLabel is used in the violation message to name the reference field.

FlatAll

A composite constraint that applies nested constraints to each element of an array or Traversable, but reports violations on the root path of the property rather than property[0], property[1], etc. Useful when validation errors should not expose array indices to clients.

#[FlatAll(constraints: [new Assert\NotBlank(), new Assert\Email()])]
public readonly array $emails;

Violations appear as emails rather than emails[0], emails[1].

AliasUriValue

Validates that a string contains only lowercase letters, uppercase letters, digits, and dashes (-). Used for URL alias/slug values.

#[AliasUriValue]
public readonly string $urlKey;

CountryRegionId

Validates that a numeric region ID belongs to a specific country. Reads the country ID from another property on the root object via countryIdTargetPath.

#[CountryRegionId(countryIdTargetPath: 'countryId')]
public readonly int $regionId;

CountryRegionIdValidator has a constructor dependency (CountryRegionRepositoryInterface) and must be bound in DI — see Validators with dependencies below.

CaptchaToken

Validates a reCAPTCHA token. Intended for public-facing forms. CaptchaTokenValidator has service dependencies and must be bound in DI.


Creating a custom constraint

A constraint is a pair of classes: a Constraint (the attribute/options holder) and a ConstraintValidator (the logic).

Constraint class

use Attribute;
use Symfony\Component\Validator\Constraint;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)]
class UniqueEmail extends Constraint
{
    public const string NOT_UNIQUE_ERROR = 'a1b2c3d4-0000-0000-0000-000000000000';

    protected const array ERROR_NAMES = [
        self::NOT_UNIQUE_ERROR => 'NOT_UNIQUE',
    ];

    public string $message = 'This email address is already in use.';

    public function __construct(
        ?array $options = null,
        ?string $message = null,
        ?array $groups = null,
        mixed $payload = null
    ) {
        parent::__construct($options ?? [], $groups, $payload);
        $this->message = $message ?? $this->message;
    }
}

Validator class

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

class UniqueEmailValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueEmail || $value === null || $value === '') {
            return;
        }

        if ($this->emailRepository->existsByEmail($value)) {
            $this->context
                ->buildViolation($constraint->message)
                ->setCode(UniqueEmail::NOT_UNIQUE_ERROR)
                ->addViolation();
        }
    }
}

Symfony resolves the validator class name from the constraint class name by appending Validator to it. No explicit registration of the pair is needed beyond the DI binding described below.


Validators with dependencies

The framework uses ContainerConstraintValidatorFactory, which resolves validator instances from the DI container. If a validator's constructor has any dependencies, the validator class must be bound in DI. If it is not bound, the factory will not be able to instantiate it and will throw an exception.

Vendor/ModuleName/Configuration/Di.php
$container->bindClass(UniqueEmailValidator::class);

bindClass is a one-liner shorthand that binds a class to itself with no extra configuration — equivalent to a bind() call with ->type(ClassName::class)->build(). It skips the binding silently if the class is already bound, so it is safe to call unconditionally.

Validators with no constructor dependencies are instantiated directly by Symfony and do not need to be bound.


Accessing the full input object from a validator

If a validator needs to read other fields beyond the validated property value, implement InputAwareConstraintInterface on the constraint class. This gives the validator access to the entire deserialized input object.

use Amarant\Framework\Contract\Validator\InputAwareConstraintInterface;
use Amarant\Framework\Contract\Data\JsonSerializableInterface;
use Attribute;
use Symfony\Component\Validator\Constraint;

#[Attribute(Attribute::TARGET_PROPERTY)]
class MyConstraint extends Constraint implements InputAwareConstraintInterface
{
    private JsonSerializableInterface|array|null $inputData = null;

    public function setInputData(JsonSerializableInterface|array $data): void
    {
        $this->inputData = $data;
    }

    public function getInputData(): JsonSerializableInterface|array|null
    {
        return $this->inputData;
    }
}

The input data is injected before validation begins, so getInputData() in the validator always returns the full input.