Validation constraints are PHP 8 attributes applied to entity properties. AbstractEntity::validate() uses Symfony's Validation::createValidatorBuilder()->enableAttributeMapping() under the hood, so all standard Symfony constraints work out of the box.
When validate() runs on an entity it:
enableAttributeMapping() and a locale-aware translator loaded from Symfony's bundled .xlf files for the current session locale (Lang::getCurrentLocale())$validator->validate($entity) which scans all properties for Symfony constraint attributesentity.field_validation_error translation key with the auto-generated field name ({entity_snake}.fields.{property_snake})All constraint error messages come from Symfony's validator translations and are fully localized to the current session locale. The validator translator is cached per locale, so switching locale mid-session works correctly.
Entities use Symfony\Component\Validator\Constraints. The common import alias:
use Symfony\Component\Validator\Constraints as Assert;
The full constraint reference is in the Symfony docs. The most commonly used ones are shown below.
Fails if the value is null, an empty string, or whitespace-only.
#[Assert\NotBlank]
#[ORM\Column( type: 'string' )]
protected string $login;
#[Assert\Length( min: 2, max: 35 )]
#[ORM\Column( type: 'string' )]
protected string $login;
#[Assert\NotBlank]
#[Assert\Email]
#[ORM\Column( type: 'string', unique: true )]
protected string $email;
#[Assert\Range( min: 1, max: 100 )]
#[ORM\Column( type: 'integer', options: [ 'default' => 1 ] )]
protected int $level = 1;
Use Assert\GreaterThanOrEqual or Assert\LessThanOrEqual for one-sided bounds.
#[Assert\NotBlank]
#[Assert\Choice( choices: [ 'draft', 'published', 'archived' ] )]
#[ORM\Column( type: 'string' )]
protected string $status = 'draft';
#[Assert\Type( type: \DateTimeInterface::class )]
#[ORM\Column( type: 'datetime', nullable: true )]
protected ?DateTimeInterface $lastLogin = null;
#[Assert\Regex( pattern: '/^-?\d+$/' )]
#[ORM\Column( type: 'bigint', nullable: true )]
protected int|string|null $colBigint = null;
#[Assert\Uuid]
#[ORM\Column( type: 'guid', nullable: true )]
protected ?string $colGuid = null;
Custom constraints follow the standard Symfony pattern — two classes: a constraint attribute and a constraint validator.
Extend Symfony\Component\Validator\Constraint:
// App/Validator/Constraints/NotBannedWord.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
#[\Attribute( \Attribute::TARGET_PROPERTY )]
class NotBannedWord extends Constraint
{
public function __construct(
public readonly array $bannedWords = [],
mixed $options = null,
?array $groups = null,
mixed $payload = null,
) {
parent::__construct( $options, $groups, $payload );
}
}
Extend Symfony\Component\Validator\ConstraintValidator and name it {ConstraintName}Validator:
// App/Validator/Constraints/NotBannedWordValidator.php
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class NotBannedWordValidator extends ConstraintValidator
{
public function validate( mixed $value, Constraint $constraint ): void
{
/** @var NotBannedWord $constraint */
if ( $value === null || $value === '' ) {
return;
}
$lower = strtolower( $value );
foreach ( $constraint->bannedWords as $word ) {
if ( str_contains( $lower, strtolower( $word ) ) ) {
$this->context->buildViolation( 'This value contains a banned word "{{ word }}".' )
->setParameter( '{{ word }}', $word )
->addViolation();
return;
}
}
}
}
use App\Validator\Constraints\NotBannedWord;
#[NotBannedWord( bannedWords: [ 'admin', 'moderator', 'system' ] )]
#[Assert\Length( min: 2, max: 35 )]
#[ORM\Column( type: 'string', unique: true )]
protected string $login;
The property label in error messages comes from the auto-generated translation key player.fields.login. Translate it in your locale file to customize the display name.
Multiple constraints on the same property all run — Symfony collects all violations rather than stopping at the first.
Wrong validator class name — Symfony resolves the validator by appending Validator to the constraint class name. NotBannedWord → NotBannedWordValidator. They must be in the same namespace. A mismatch causes a RuntimeException at validation time.
Calling addViolation() and continuing — unlike a boolean return, addViolation() doesn't stop execution. If you only want one error per invocation, return immediately after calling it.
Throwing exceptions in validate() — validation logic should add violations, not throw. Exceptions bubble up through AbstractEntity::validate() and cause a 500 instead of a validation error response.
Using Symfony's {{ param }} in your message string with _t() — Symfony's buildViolation() uses {{ param }} placeholders internally; _t() uses {param}. Don't mix them. Use buildViolation() for the constraint message, and reference the translated field label via _t() only outside the constraint validator (e.g. in the entity's error collection).