如何创建自定义验证约束
您可以通过扩展基础约束类 Constraint 来创建自定义约束。例如,您将创建一个基本的验证器,检查字符串是否仅包含字母数字字符。
创建约束类
首先,您需要创建一个 Constraint 类并扩展 Constraint
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';
public string $mode = 'strict';
// all configurable options must be passed to the constructor
public function __construct(?string $mode = null, ?string $message = null, ?array $groups = null, $payload = null)
{
parent::__construct([], $groups, $payload);
$this->mode = $mode ?? $this->mode;
$this->message = $message ?? $this->message;
}
}
如果您想在其他类中将其用作属性,请将 #[\Attribute]
添加到约束类。
您可以使用 #[HasNamedArguments]
使某些约束选项成为必需
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';
#[HasNamedArguments]
public function __construct(
public string $mode,
?array $groups = null,
mixed $payload = null,
) {
parent::__construct([], $groups, $payload);
}
}
具有私有属性的约束
出于性能原因,约束会被缓存。为了实现这一点,基础 Constraint
类使用了 PHP 的 get_object_vars 函数,该函数排除了子类的私有属性。
如果您的约束定义了私有属性,则必须在 __sleep()
方法中显式包含它们,以确保它们被正确序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// src/Validator/ContainsAlphanumeric.php
namespace App\Validator;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsAlphanumeric extends Constraint
{
public string $message = 'The string "{{ string }}" contains an illegal character: it can only contain letters or numbers.';
#[HasNamedArguments]
public function __construct(
private string $mode,
?array $groups = null,
mixed $payload = null,
) {
parent::__construct([], $groups, $payload);
}
public function __sleep(): array
{
return array_merge(
parent::__sleep(),
[
'mode'
]
);
}
}
创建验证器本身
如您所见,约束类非常简洁。实际的验证由另一个“约束验证器”类执行。约束验证器类由约束的 validatedBy()
方法指定,该方法具有以下默认逻辑
1 2 3 4 5
// in the base Symfony\Component\Validator\Constraint class
public function validatedBy(): string
{
return static::class.'Validator';
}
换句话说,如果您创建自定义 Constraint
(例如 MyConstraint
),Symfony 将在实际执行验证时自动查找另一个类 MyConstraintValidator
。
验证器类只有一个必需的方法 validate()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
// src/Validator/ContainsAlphanumericValidator.php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class ContainsAlphanumericValidator extends ConstraintValidator
{
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof ContainsAlphanumeric) {
throw new UnexpectedTypeException($constraint, ContainsAlphanumeric::class);
}
// custom constraints should ignore null and empty values to allow
// other constraints (NotBlank, NotNull, etc.) to take care of that
if (null === $value || '' === $value) {
return;
}
if (!is_string($value)) {
// throw this exception if your validator cannot handle the passed type so that it can be marked as invalid
throw new UnexpectedValueException($value, 'string');
// separate multiple types using pipes
// throw new UnexpectedValueException($value, 'string|int');
}
// access your configuration options like this:
if ('strict' === $constraint->mode) {
// ...
}
if (preg_match('/^[a-zA-Z0-9]+$/', $value, $matches)) {
return;
}
// the argument must be a string or an object implementing __toString()
$this->context->buildViolation($constraint->message)
->setParameter('{{ string }}', $value)
->addViolation();
}
}
在 validate()
内部,您无需返回值。相反,您需要将违规添加到验证器的 context
属性,如果没有违规,则该值将被视为有效。buildViolation()
方法将错误消息作为其参数,并返回 ConstraintViolationBuilderInterface 的实例。addViolation()
方法调用最终将违规添加到上下文中。
使用新的验证器
您可以像使用 Symfony 本身提供的验证器一样使用自定义验证器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// src/Entity/AcmeEntity.php
namespace App\Entity;
use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;
class AcmeEntity
{
// ...
#[Assert\NotBlank]
#[AcmeAssert\ContainsAlphanumeric(mode: 'loose')]
protected string $name;
// ...
}
如果您的约束包含选项,则它们应该是您之前创建的自定义 Constraint 类上的公共属性。这些选项可以像核心 Symfony 约束上的选项一样配置。
带有依赖项的约束验证器
如果您使用默认的 services.yaml 配置,那么您的验证器已经注册为服务,并使用必要的 validator.constraint_validator
标记。这意味着您可以像任何其他服务一样注入服务或配置。
带有自定义选项的约束验证器
如果您想向自定义约束添加一些配置选项,首先在约束类上将这些选项定义为公共属性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
// src/Validator/Foo.php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class Foo extends Constraint
{
public $mandatoryFooOption;
public $message = 'This value is invalid';
public $optionalBarOption = false;
public function __construct(
$mandatoryFooOption,
?string $message = null,
?bool $optionalBarOption = null,
?array $groups = null,
$payload = null,
array $options = []
) {
if (\is_array($mandatoryFooOption)) {
$options = array_merge($mandatoryFooOption, $options);
} elseif (null !== $mandatoryFooOption) {
$options['value'] = $mandatoryFooOption;
}
parent::__construct($options, $groups, $payload);
$this->message = $message ?? $this->message;
$this->optionalBarOption = $optionalBarOption ?? $this->optionalBarOption;
}
public function getDefaultOption(): string
{
return 'mandatoryFooOption';
}
public function getRequiredOptions(): array
{
return ['mandatoryFooOption'];
}
}
然后,在验证器类内部,您可以直接通过传递给 validate()
方法的约束类来访问这些选项
1 2 3 4 5 6 7 8 9 10 11 12
class FooValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
// access any option of the constraint
if ($constraint->optionalBarOption) {
// ...
}
// ...
}
}
在您自己的应用程序中使用此约束时,您可以像传递内置约束中的任何其他选项一样传递自定义选项的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Entity/AcmeEntity.php
namespace App\Entity;
use App\Validator as AcmeAssert;
use Symfony\Component\Validator\Constraints as Assert;
class AcmeEntity
{
// ...
#[Assert\NotBlank]
#[AcmeAssert\Foo(
mandatoryFooOption: 'bar',
optionalBarOption: true
)]
protected $name;
// ...
}
创建可重用的一组约束
如果您需要在整个应用程序中一致地应用一组常见的约束,则可以扩展 复合约束。
类约束验证器
除了验证单个属性之外,约束还可以将整个类作为其作用域。
例如,假设您还有一个 PaymentReceipt
实体,并且您需要确保收据有效负载的电子邮件与用户的电子邮件匹配。首先,创建一个约束并覆盖 getTargets()
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// src/Validator/ConfirmedPaymentReceipt.php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ConfirmedPaymentReceipt extends Constraint
{
public string $userDoesNotMatchMessage = 'User\'s e-mail address does not match that of the receipt';
public function getTargets(): string
{
return self::CLASS_CONSTRAINT;
}
}
现在,约束验证器将获取一个对象作为 validate()
的第一个参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// src/Validator/ConfirmedPaymentReceiptValidator.php
namespace App\Validator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
class ConfirmedPaymentReceiptValidator extends ConstraintValidator
{
/**
* @param PaymentReceipt $receipt
*/
public function validate($receipt, Constraint $constraint): void
{
if (!$receipt instanceof PaymentReceipt) {
throw new UnexpectedValueException($receipt, PaymentReceipt::class);
}
if (!$constraint instanceof ConfirmedPaymentReceipt) {
throw new UnexpectedValueException($constraint, ConfirmedPaymentReceipt::class);
}
$receiptEmail = $receipt->getPayload()['email'] ?? null;
$userEmail = $receipt->getUser()->getEmail();
if ($userEmail !== $receiptEmail) {
$this->context
->buildViolation($constraint->userDoesNotMatchMessage)
->atPath('user.email')
->addViolation();
}
}
}
提示
atPath()
方法定义了与验证错误关联的属性。使用任何有效的 PropertyAccess 语法来定义该属性。
类约束验证器必须应用于类本身
1 2 3 4 5 6 7 8 9 10
// src/Entity/AcmeEntity.php
namespace App\Entity;
use App\Validator as AcmeAssert;
#[AcmeAssert\ConfirmedPaymentReceipt]
class AcmeEntity
{
// ...
}
测试自定义约束
原子约束
使用 ConstraintValidatorTestCase 类来简化为自定义约束编写单元测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
// tests/Validator/ContainsAlphanumericValidatorTest.php
namespace App\Tests\Validator;
use App\Validator\ContainsAlphanumeric;
use App\Validator\ContainsAlphanumericValidator;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
class ContainsAlphanumericValidatorTest extends ConstraintValidatorTestCase
{
protected function createValidator(): ConstraintValidatorInterface
{
return new ContainsAlphanumericValidator();
}
public function testNullIsValid(): void
{
$this->validator->validate(null, new ContainsAlphanumeric());
$this->assertNoViolation();
}
/**
* @dataProvider provideInvalidConstraints
*/
public function testTrueIsInvalid(ContainsAlphanumeric $constraint): void
{
$this->validator->validate('...', $constraint);
$this->buildViolation('myMessage')
->setParameter('{{ string }}', '...')
->assertRaised();
}
public function provideInvalidConstraints(): \Generator
{
yield [new ContainsAlphanumeric(message: 'myMessage')];
// ...
}
}
复合约束
考虑以下复合约束,该约束检查字符串是否满足您的密码策略的最低要求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// src/Validator/PasswordRequirements.php
namespace App\Validator;
use Symfony\Component\Validator\Constraints as Assert;
#[\Attribute]
class PasswordRequirements extends Assert\Compound
{
protected function getConstraints(array $options): array
{
return [
new Assert\NotBlank(allowNull: false),
new Assert\Length(min: 8, max: 255),
new Assert\NotCompromisedPassword(),
new Assert\Type('string'),
new Assert\Regex('/[A-Z]+/'),
];
}
}
您可以使用 CompoundConstraintTestCase 类来精确检查哪些约束未能通过
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// tests/Validator/PasswordRequirementsTest.php
namespace App\Tests\Validator;
use App\Validator\PasswordRequirements;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Test\CompoundConstraintTestCase;
/**
* @extends CompoundConstraintTestCase<PasswordRequirements>
*/
class PasswordRequirementsTest extends CompoundConstraintTestCase
{
public function createCompound(): Assert\Compound
{
return new PasswordRequirements();
}
public function testInvalidPassword(): void
{
$this->validateValue('azerty123');
// check all constraints pass except for the
// password leak and the uppercase letter checks
$this->assertViolationsRaisedByCompound([
new Assert\NotCompromisedPassword(),
new Assert\Regex('/[A-Z]+/'),
]);
}
public function testValid(): void
{
$this->validateValue('VERYSTR0NGP4$$WORD#%!');
$this->assertNoViolation();
}
}
7.2
CompoundConstraintTestCase 类在 Symfony 7.2 中引入。