Validation constraints are PHP 8 attributes applied to entity properties. When $entity->validate() is called, the framework reads these attributes via reflection and runs the corresponding validator class for each one. This page covers all built-in constraints and explains how to create custom ones.
Each constraint is a pair of two classes:
{ConstraintClass}Validator, contains the actual validation logicThe naming convention is strict and enforced automatically — AbstractConstraintValidator extracts the constraint class name from the validator class name via string manipulation. A validator named LengthValidator must validate a constraint named Length. This means custom constraints must follow the same naming pattern.
When validate() runs on an entity, for each property it:
ReflectionProperty::getAttributes()AbstractConstraintsetPropertyName() on the constraint to store which property it belongs to{ConstraintClassName}Validator with the current property value, constraint instance, and entityvalidate() on the validatorgetError()All built-in constraints are in the PHP_SF\System\Attributes\Validator\Constraints namespace. The common import alias is:
use PHP_SF\System\Attributes\Validator\Constraints as Validate;
Validates string length between min and max characters.
#[Validate\Length( min: 2, max: 35 )]
#[TranslatablePropertyName( 'Login' )]
#[ORM\Column( type: 'string' )]
protected string $login;
| Parameter | Type | Required | Default |
|---|---|---|---|
min |
int |
Yes | — |
max |
int |
Yes | — |
allowNull |
bool\|null |
No | false |
Error messages:
Field {name} is too short. It should have {min} character or more.Field {name} is too long. It should have {max} character or less.Validates that the value is a valid email address using PHP's FILTER_VALIDATE_EMAIL.
#[Validate\Email]
#[TranslatablePropertyName( 'E-mail' )]
#[ORM\Column( type: 'string' )]
protected string $email;
No parameters.
Error message: Field {name} is not a valid email address.
Validates that a numeric value falls between min and max inclusive.
#[Validate\Range( min: 1, max: 100 )]
#[TranslatablePropertyName( 'Level' )]
#[ORM\Column( type: 'integer' )]
protected int $level;
| Parameter | Type | Required | Default |
|---|---|---|---|
min |
int\|float |
Yes | — |
max |
int\|float |
Yes | — |
allowNull |
bool\|null |
No | false |
Error message: Field {name} should be between {min} and {max}!
Validates that a numeric value is greater than or equal to the given value.
#[Validate\Min( 0 )]
#[TranslatablePropertyName( 'Gold' )]
#[ORM\Column( type: 'integer' )]
protected int $gold;
| Parameter | Type | Required |
|---|---|---|
value |
int\|float |
Yes |
Error message: min_value_validation_error (translation key — add to locale files with your preferred wording).
Validates that a numeric value is less than or equal to the given value.
#[Validate\Max( 999 )]
#[TranslatablePropertyName( 'Inventory Size' )]
#[ORM\Column( type: 'integer' )]
protected int $inventorySize;
| Parameter | Type | Required |
|---|---|---|
value |
int\|float |
Yes |
Error message: max_value_validation_error (translation key).
Validates that the value is an instance of PHP_SF\System\Core\DateTime.
#[Validate\DateTime]
#[TranslatablePropertyName( 'Last Login' )]
#[ORM\Column( type: 'datetime', nullable: true )]
protected string|DateTimeInterface|null $lastLogin = null;
| Parameter | Type | Required | Default |
|---|---|---|---|
allowNull |
bool\|null |
No | false |
Error message: datetime_validation_error (translation key).
Validates that the value is one of the allowed strings or integers.
#[Validate\OneOfTheValues( [ 'archer', 'warrior', 'mage' ] )]
#[TranslatablePropertyName( 'Class' )]
#[ORM\Column( type: 'string' )]
protected string $class;
| Parameter | Type | Required |
|---|---|---|
arr |
array |
Yes |
The arr parameter must contain only strings and integers — other types throw UnexpectedValueException at attribute instantiation, not at validation time.
Error message: Field {name} must be one of these values: ({values})
Same as OneOfTheValues but restricted to integers only.
#[Validate\OneOfTheNumbers( [ 1, 3, 6 ] )]
#[TranslatablePropertyName( 'User Group ID' )]
#[ORM\Column( type: 'integer' )]
protected int $userGroupId;
| Parameter | Type | Required |
|---|---|---|
numbers |
array |
Yes |
All values in numbers must be integers — throws UnexpectedValueException otherwise.
Error message: Field {name} must be one of these numbers: ({numbers})
All validators call $this->isDefaultValue() at the start of validate() and return true immediately if the current value matches the column's options['default']. This prevents false validation failures on freshly created entities:
#[Validate\Range( min: 1, max: 100 )]
#[TranslatablePropertyName( 'Level' )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
If $level is 1 (the default), Range validation is skipped. The check is done in AbstractConstraintValidator::isDefaultValue() via reflection on the #[ORM\Column] attribute. When writing custom validators, call $this->isDefaultValue() first if your property has a default.
Custom constraints require exactly two files following the mandatory naming convention.
Extend AbstractConstraint and mark it as a TARGET_PROPERTY attribute:
// src/Attributes/Validator/Constraints/NotBannedWord.php
// or anywhere in your autoloaded namespace
namespace App\Validator\Constraints;
use Attribute;
use PHP_SF\System\Classes\Abstracts\AbstractConstraint;
#[Attribute( Attribute::TARGET_PROPERTY )]
final class NotBannedWord extends AbstractConstraint
{
public function __construct(
public readonly array $bannedWords = [],
public readonly bool|null $allowNull = null,
) {}
}
The constraint class holds only configuration — no validation logic. Keep it minimal.
Create a class named exactly {ConstraintName}Validator in the same namespace, extending AbstractConstraintValidator:
// App/Validator/Constraints/NotBannedWordValidator.php
namespace App\Validator\Constraints;
use PHP_SF\System\Classes\Abstracts\AbstractConstraintValidator;
/**
* @property NotBannedWord $constraint
*/
final class NotBannedWordValidator extends AbstractConstraintValidator
{
public function validate(): bool
{
// Skip if default value
if ( $this->isDefaultValue() )
return true;
// Skip if null is allowed and value is null
if ( $this->constraint->allowNull === true && $this->getValue() === null )
return true;
$value = strtolower( $this->getValue() );
foreach ( $this->constraint->bannedWords as $word )
if ( str_contains( $value, strtolower( $word ) ) ) {
$this->setError(
'Field `%s` contains a banned word: "%s".',
_t( $this->getTranslatablePropertyName() ),
$word
);
return false;
}
return true;
}
}
use App\Validator\Constraints\NotBannedWord;
#[NotBannedWord( bannedWords: [ 'admin', 'moderator', 'system' ] )]
#[Validate\Length( min: 2, max: 35 )]
#[TranslatablePropertyName( 'Login' )]
#[ORM\Column( type: 'string', unique: true )]
protected string $login;
Multiple constraints on the same property all run — validation doesn't stop at the first failure per property unless the property itself is null/unset.
These methods are available inside validate():
protected function getValue(): mixed
Returns the current property value being validated.
protected function isDefaultValue(): bool
Returns true if the current value matches the options['default'] on the #[ORM\Column] attribute. Call this first in every custom validator to respect column defaults.
final public function setError( string $error, ...$values ): void
Sets the validation error message. The first argument is passed through _t() — use a translation key or a plain string. Additional arguments are sprintf placeholders:
// Plain string with placeholders
$this->setError(
'Field `%s` contains a banned word: "%s".',
_t( $this->getTranslatablePropertyName() ),
$word
);
// Translation key
$this->setError( 'my_custom_validation_error_key' );
// Translation key with placeholders
$this->setError(
'Field `%s` must be positive.',
_t( $this->getTranslatablePropertyName() )
);
final protected function getTranslatablePropertyName(): string
Returns the value of #[TranslatablePropertyName] for the current property. Throws InvalidEntityConfigurationException if the attribute is missing. Use this in error messages to include the human-readable property name:
$this->setError(
'Field `%s` is invalid.',
_t( $this->getTranslatablePropertyName() )
);
// → "Field `Login` is invalid." (translated)
final public function getValidatedClass(): AbstractEntity
Returns the entity instance being validated. Use when your validation logic needs to check other properties on the same entity:
public function validate(): bool
{
$entity = $this->getValidatedClass();
// Cross-property validation
if ( $this->getValue() < $entity->getMinValue() ) {
$this->setError(
'Field `%s` must be greater than minimum value.',
_t( $this->getTranslatablePropertyName() )
);
return false;
}
return true;
}
The constraint/validator pair naming is enforced by AbstractConstraintValidator::__construct():
public function __construct(
private mixed $value,
protected AbstractConstraint $constraint,
protected AbstractEntity $validatedClass
) {
$arr = explode( 'Validator', static::class );
$constraintClass = sprintf( '%sValidator%s', $arr[0], $arr[1] );
if ( !$this->constraint instanceof $constraintClass )
throw new UnexpectedTypeException( $this->constraint, $constraintClass );
}
If MyConstraintValidator is instantiated with anything other than a MyConstraint instance, it throws immediately. This means the naming convention isn't just a guideline — it's enforced at instantiation. Name your classes correctly or they won't work.
A more complex example — validating game coordinates within a map boundary:
// App/Validator/Constraints/ValidCoordinate.php
#[Attribute( Attribute::TARGET_PROPERTY )]
final class ValidCoordinate extends AbstractConstraint
{
public function __construct(
public readonly int $mapSize,
public readonly bool|null $allowNull = null,
) {}
}
// App/Validator/Constraints/ValidCoordinateValidator.php
/**
* @property ValidCoordinate $constraint
*/
final class ValidCoordinateValidator extends AbstractConstraintValidator
{
public function validate(): bool
{
if ( $this->isDefaultValue() )
return true;
if ( $this->constraint->allowNull === true && $this->getValue() === null )
return true;
$value = $this->getValue();
if ( $value < 0 || $value >= $this->constraint->mapSize ) {
$this->setError(
'Field `%s` must be between 0 and %s.',
_t( $this->getTranslatablePropertyName() ),
$this->constraint->mapSize - 1
);
return false;
}
return true;
}
}
// On an entity
#[ValidCoordinate( mapSize: 512 )]
#[TranslatablePropertyName( 'X Coordinate' )]
#[ORM\Column( type: 'integer' )]
protected int $x;
#[ValidCoordinate( mapSize: 512 )]
#[TranslatablePropertyName( 'Y Coordinate' )]
#[ORM\Column( type: 'integer' )]
protected int $y;
Validator class in wrong namespace or wrong name — the framework derives the validator class name from the constraint class name via string manipulation. If MyConstraint is in App\Validator\Constraints but MyConstraintValidator is in App\Validator (different namespace), instantiation fails with UnexpectedTypeException. Both must be in the same namespace.
Forgetting isDefaultValue() in custom validators — without this check, a property with a column default will fail validation on a freshly created entity even when the value was never explicitly set. Always call $this->isDefaultValue() as the first check.
Not returning false after setError() — setError() sets the error message but doesn't stop execution or return anything. You must explicitly return false after calling it, otherwise validation appears to pass despite the error being set.
Throwing exceptions in validate() — validation logic should set an error and return false for invalid values, not throw exceptions. Exceptions thrown inside validate() bubble up through AbstractEntity::validate() as uncaught exceptions, bypassing the error collection system and causing a 500 instead of a validation error page.
Multiple setError() calls — calling setError() multiple times in a single validate() method only preserves the last error. If you need to report multiple errors from a single constraint, concatenate them into a single error message or split the logic into multiple constraint classes.