如何建立自訂驗證約束
您可以透過擴展基礎約束類別 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 中引入的。