跳到內容

如何建立自訂驗證約束

編輯此頁面

您可以透過擴展基礎約束類別 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 授權條款授權。
目錄
    版本