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 (defined in the framework's lang/en.yaml as "Field `{field}`: {message}`") with the auto-generated field name ({entity_snake}.fields.{property_snake})The field parameter is a lazy @:key reference (see Lazy references below) — at render time it resolves to the localized field label like Email or Login. The message parameter is Symfony's already-translated violation message, with any {{ word }}-style placeholders already substituted.
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.
There are two different placeholder conventions in play and they are not interchangeable:
| Convention | Form | Where it is used |
|---|---|---|
| Symfony validator | {{ word }} |
Constraint message strings passed to buildViolation() — Symfony's translator handles these via setParameter('{{ word }}', $value) |
Project translator (_t()) |
{name} |
Translation YAML values (e.g. "Field `{field}`: {message}`") — TranslatorV2::resolveValue() interpolates these via ICU MessageFormatter when intl is loaded, or strtr otherwise |
Inside a custom constraint validator:
// Symfony's convention — {{ word }} + setParameter()
$this->context->buildViolation('This value contains a banned word "{{ word }}".')
->setParameter('{{ word }}', $word) // Symfony interpolates at validation time
->addViolation();
The rendered message ("This value contains a banned word \"admin\".") is what $violation->getMessage() returns. That string is then passed as the message parameter to _t('entity.field_validation_error', [...]) and ends up verbatim between the backticks in "Field `{field}`: {message}`".
Never write {{ word }} in a YAML value consumed by _t() — the framework's translator only handles {name}. And never write {name} in a Symfony buildViolation() message — Symfony will treat it as a literal.
AbstractEntity::getTranslatablePropertyName() produces a snake_case key like user.fields.email. The field parameter is set to '@:' . $key, which TranslatorV2 resolves at render time (same locale first, then DEFAULT_LOCALE), depth-capped at MAX_REF_DEPTH (5). On a miss or cycle the literal @:key string is returned — the same mechanism described in AGENTS.md for nesting labels into messages without duplicating text.
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.
AbstractEntity::validate() returns true on success. On failure, getValidationErrors() returns either true (when no errors) or array<string, string> keyed by Symfony's property path (e.g. 'email', 'login') with the already-wrapped, fully translated message as the value:
$user = new User();
$user->setLogin(''); // violates NotBlank
if (false === $user->validate()) {
/** @var array<string, string> $errors */
$errors = $user->getValidationErrors(); // ['login' => 'Field `Login`: Login should not be blank']
}
The wrapper value comes from the framework's lang/en.yaml:
entity.field_validation_error: "Field `{field}`: {message}"
{field} is a lazy @:user.fields.login-style reference; {message} is the Symfony violation message with {{ word }}-style placeholders already substituted. Translators add a localised version under the same key in any locale file shipped by the framework (the framework itself only ships lang/en.yaml — additional locales are added by the application as needed).
getValidationErrors() is the single read path — validationErrors is private and intentionally not part of the entity's public API.
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).