symfony/validator
The Symfony Validator component provides tools to validate values following the JSR-303 Bean Validation specification, with constraints, annotations, and attributes for expressive, reusable validation rules.
Installation:
composer require symfony/validator
Laravel pulls in Symfony components for several features, so the validator is usually already present in vendor/. Confirm with composer show symfony/validator before adding anything. You only need explicit requires for optional pieces such as symfony/expression-language (for the Expression constraint) or symfony/property-access (for getPropertyPath() on object graphs).
Know Which Line You're On:
composer show symfony/validator | grep -E '^versions'
composer audit
Symfony backports security fixes to every supported branch, so the same fix can ship as 5.4.x, 6.4.x, 7.x and 8.1.x. v5.4.54 is a security release (see Gotchas) — if composer show puts you on 5.4, treat that upgrade as mandatory, and if you're on 8.1.x confirm the equivalent patch is in your line before assuming you're covered.
First Use Case: Validate a DTO or request payload in a Laravel controller:
use Symfony\Component\Validator\Validator\ValidatorInterface;
public function store(Request $request, ValidatorInterface $validator)
{
$data = $request->validate([
'email' => 'required|email',
'age' => 'integer|min:18',
]);
$errors = $validator->validate($data);
if (count($errors) > 0) {
return response()->json(['errors' => (string) $errors], 400);
}
// Proceed with valid data
}
Key Classes to Know:
ValidatorInterface: Core validator service (injected via Laravel's container).Constraint: Base class for custom validation rules.ConstraintValidator: Validates a specific constraint.ConstraintViolationListInterface: Holds validation results.Where to Look First:
Request::validate() (the "Laravel-flavoured" alternative you'll often pair this with).app/Providers/AppServiceProvider.php for global validation configurations.Leverage Laravel's FormRequest to auto-validate with Symfony's constraints:
use Symfony\Component\Validator\Constraints as Assert;
class StoreUserRequest extends FormRequest
{
public function rules()
{
return [
'name' => 'required|string|max:255',
'email' => ['required', 'email', new Assert\Email()],
];
}
public function authorize()
{
return true;
}
public function validated()
{
$violations = app(ValidatorInterface::class)->validate($this->all());
if ($violations->count() > 0) {
$messages = [];
foreach ($violations as $violation) {
$messages[$violation->getPropertyPath()][] = $violation->getMessage();
}
throw \Illuminate\Validation\ValidationException::withMessages($messages);
}
return parent::validated();
}
}
Create reusable validation rules:
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
#[Attribute]
class ValidPassword extends Constraint
{
public string $message = 'The password must contain at least one uppercase letter, one lowercase letter, and one number.';
}
class ValidPasswordValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/', $value)) {
$this->context->buildViolation($constraint->message)
->addViolation();
}
}
}
Usage:
use App\Validator\Constraints\ValidPassword;
#[ValidPassword]
public string $password;
Validate different subsets of data:
$validator = app(ValidatorInterface::class);
$violations = $validator->validate($user, null, ['registration']); // Only validate 'registration' group
Reuse validation logic across services:
class UserService
{
public function __construct(private ValidatorInterface $validator) {}
public function createUser(array $data): void
{
$violations = $this->validator->validate($data);
if ($violations->count() > 0) {
throw new \RuntimeException((string) $violations);
}
// Save user
}
}
Format validation errors for JSON APIs:
$errors = $validator->validate($data);
return response()->json([
'errors' => collect($errors)->map(fn ($error) => $error->getPropertyPath() . ': ' . $error->getMessage())
]);
Configure a validator built with explicit cache/metadata settings in AppServiceProvider:
use Symfony\Component\Validator\Validation;
public function register()
{
$this->app->singleton(ValidatorInterface::class, function () {
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->setMappingCache(storage_path('framework/cache/validator'))
->getValidator();
});
}
Because Expression pulls in a separate, security-sensitive parser, isolate it:
// config/validator.php or a bound constraint factory
'allow_expression_constraint' => env('VALIDATOR_ALLOW_EXPRESSION', false),
Gate dynamic-expression features behind a flag so a language-component security bump is a one-config-rollback away.
Expression Parsing Is Bounded as of 5.4.54 (Security Release):
Expression constraint compiles its expression through symfony/expression-language, and deeply nested expressions can exhaust the stack. Two consequences:
6.4, 7.x, 8.1.x) — composer audit and the Symfony security advisories page are the source of truth.Callback constraint (plain PHP, no parser) which is both safer and easier to reason about.Circular References in Validation:
User->posts->author->user). Use @Assert\Valid sparingly or disable it for specific properties.Performance with Large Datasets:
cache_dir in config). For dynamic validation rules, disable caching or use ValidatorBuilder to rebuild the validator:
$validator = ValidatorBuilder::create()
->enableAnnotationMapping()
->getValidator();
Constraint Overrides:
#[Assert\Length(min: 5)]) may not work as expected if the parent constraint's options aren't properly inherited. Use #[Assert\Callback] for complex logic:
#[Assert\Callback]
public function validate($value, ExecutionContextInterface $context)
{
if (!str_contains($value, 'laravel')) {
$context->buildViolation('Must contain "laravel".')
->addViolation();
}
}
Validation Groups in Laravel:
FormRequest doesn't natively support Symfony's validation groups. Use a custom validator or manually pass groups:
$validator = app(ValidatorInterface::class);
$validator->validate($data, null, ['group1', 'group2']);
Expression Language Quirks:
Expression constraint (@Assert\Expression) requires the symfony/expression-language component. Install it separately:
composer require symfony/expression-language
symfony/expression-language pinned in lockstep with symfony/validator — the nesting bound lives in the parser, so a mismatched pair leaves you "on the patched validator" but still running an unpatched parser.#[Assert\Expression(
expression: "this.getAge() >= 18",
message: "You must be at least 18 years old."
)]
Type Safety:
#[Assert\Type(type: 'string')] or #[Assert\Type(type: 'array')].Week Accepts a Blank String (as of 8.1.9):
Week now treats '' as valid, so an optional form field left empty will pass silently. If the field is genuinely required, pair it with #[Assert\NotBlank] or a required rule; if it's optional, you get free "empty means no value" behaviour. Review any existing tests that asserted a violation on an empty week value — those will now fail.Attribute Changes on Non-Public Members Require a Container/Cache Rebuild:
php artisan optimize:clear (or redeploy with a fresh build) rather than assuming the compiled container picks it up. Stale metadata caches are the most common cause of "my new constraint isn't firing" in production.Inspect Violations:
$violations = $validator->validate($data);
foreach ($violations as $violation) {
dump([
'property' => $violation->getPropertyPath(),
'message' => $violation->getMessage(),
'code' => $violation->getCode(),
]);
}
Enable Validation Tracing:
validate() calls and timing, then bind it in a service provider. This gives you the "traceable validator" behaviour without relying on config keys Laravel doesn't ship.Confirm Which Version Is Actually Loaded:
composer show symfony/validator symfony/expression-language
Common Error Codes:
SYMFONY\Component\Validator\Constraints\Length: Use min/max in error messages:
#[Assert\Length(
min: 5,
minMessage: 'This value should be at least {{ limit }} characters long.',
max: 20,
maxMessage: 'This value should be at most {{ limit }} characters long.'
)]
Translation Noise:
translations/validators.<locale>.*, re-check your placeholders after upgrading — malformed {{ }} tokens were silently breaking interpolated values before this patch. Note this is orthogonal to the 5.4.54 security fix; if you jump branches, expect both changes at once.Custom Constraint Validators:
ConstraintValidatorInterface for complex rules:
class CustomValidator extends AbstractConstraintValidator
{
public function validate($value, Constraint $constraint)
{
if ($value !== $constraint->expected) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', $value)
->addViolation();
}
}
}
Validation Event Listeners:
$dispatcher = app(\Symfony\Component\EventDispatcher\EventDispatcherInterface::class);
$dispatcher->addListener(ConstraintViolationListEvent::class, function ($event) {
// Log violations or modify them
});
Override Default Constraints:
Email) by binding a custom validator in Laravel's service container:
$this->app->bind(EmailValidator::class, function () {
return new CustomEmailValidator();
});
Validation in Tests:
ValidatorInterface in PHPUnit tests:
public function testValidation()
{
$validator = $this->app->make(ValidatorInterface::class);
$violations = $validator->validate(['email' => 'invalid']);
$this->assertCount(1, $violations);
}
Laravel Policy Integration:
CI Guard for Security Releases:
composer audit --locked --no-dev to CI and a scheduled weekly job. For a low-churn component like this, a single-fix security release (exactly what 5.4.54 is) is easy to miss between feature upgrades, and it's the kind of change that ships with no new API surface — nothing else in your pipeline will flag it.How can I help you explore Laravel packages today?