跳到内容

如何创建自定义验证约束

编辑此页

您可以通过扩展基础约束类 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 中引入。

这项工作,包括代码示例,根据 Creative Commons BY-SA 3.0 许可获得许可。
TOC
    版本