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