表单验证 #

一、验证概述 #

1.1 验证组件 #

Symfony验证组件提供强大的数据验证功能:

text
┌─────────────────────────────────────────────────────┐
│                  验证功能                            │
├─────────────────────────────────────────────────────┤
│  • 内置验证约束                                     │
│  • 自定义验证规则                                   │
│  • 验证组                                           │
│  • 错误消息定制                                     │
│  • 实体验证                                         │
│  • 表单验证集成                                     │
└─────────────────────────────────────────────────────┘

1.2 安装验证组件 #

bash
# 安装验证组件
composer require validator

二、实体约束 #

2.1 基本约束 #

php
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
class User
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    #[Assert\NotBlank(message: '姓名不能为空')]
    #[Assert\Length(
        min: 2,
        max: 100,
        minMessage: '姓名至少{{ limit }}个字符',
        maxMessage: '姓名最多{{ limit }}个字符'
    )]
    private ?string $name = null;

    #[ORM\Column(length: 180, unique: true)]
    #[Assert\NotBlank(message: '邮箱不能为空')]
    #[Assert\Email(message: '邮箱格式不正确')]
    #[Assert\Length(max: 180)]
    private ?string $email = null;

    #[ORM\Column]
    #[Assert\NotBlank(message: '密码不能为空')]
    #[Assert\Length(min: 8, minMessage: '密码至少{{ limit }}个字符')]
    #[Assert\Regex(
        pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/',
        message: '密码必须包含大小写字母和数字'
    )]
    private ?string $password = null;

    #[ORM\Column]
    #[Assert\Range(
        min: 0,
        max: 150,
        notInRangeMessage: '年龄必须在{{ min }}到{{ max }}之间'
    )]
    private int $age = 0;
}

2.2 字符串约束 #

php
<?php

use Symfony\Component\Validator\Constraints as Assert;

class Article
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 5, max: 200)]
    private ?string $title = null;

    #[Assert\NotBlank]
    #[Assert\Length(min: 10)]
    private ?string $content = null;

    #[Assert\Url(message: '请输入有效的URL')]
    private ?string $website = null;

    #[Assert\Regex(
        pattern: '/^[a-zA-Z0-9-]+$/',
        message: '只能包含字母、数字和横线'
    )]
    private ?string $slug = null;

    #[Assert\Language(message: '请输入有效的语言代码')]
    private ?string $language = null;

    #[Assert\Locale(message: '请输入有效的区域设置')]
    private ?string $locale = null;

    #[Assert\Country(message: '请输入有效的国家代码')]
    private ?string $country = null;
}

2.3 数字约束 #

php
<?php

class Product
{
    #[Assert\Positive(message: '价格必须大于0')]
    private float $price = 0;

    #[Assert\PositiveOrZero(message: '库存不能为负数')]
    private int $stock = 0;

    #[Assert\Range(min: 1, max: 100)]
    private int $quantity = 1;

    #[Assert\LessThan(value: 1000, message: '不能超过1000')]
    private float $amount = 0;

    #[Assert\GreaterThan(value: 0, message: '必须大于0')]
    private float $discount = 0;

    #[Assert\LessThanOrEqual(propertyPath: 'stock')]
    private int $orderQuantity = 0;
}

2.4 日期约束 #

php
<?php

class Event
{
    #[Assert\NotBlank]
    #[Assert\Date(message: '请输入有效的日期')]
    private ?\DateTimeInterface $startDate = null;

    #[Assert\NotBlank]
    #[Assert\Date]
    private ?\DateTimeInterface $endDate = null;

    #[Assert\GreaterThan(propertyPath: 'startDate', message: '结束日期必须晚于开始日期')]
    private ?\DateTimeInterface $endDate = null;

    #[Assert\Range(
        min: 'now',
        max: '+1 year',
        minMessage: '日期不能早于今天',
        maxMessage: '日期不能超过一年'
    )]
    private ?\DateTimeInterface $eventDate = null;
}

2.5 集合约束 #

php
<?php

class Order
{
    #[Assert\NotBlank]
    #[Assert\Count(
        min: 1,
        max: 10,
        minMessage: '至少选择一个商品',
        maxMessage: '最多选择{{ limit }}个商品'
    )]
    private array $items = [];

    #[Assert\All([
        new Assert\NotBlank(),
        new Assert\Email(),
    ])]
    private array $emails = [];

    #[Assert\Unique(message: '标签不能重复')]
    private array $tags = [];
}

三、表单约束 #

3.1 表单字段约束 #

php
<?php

use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Validator\Constraints\File;
use Symfony\Component\Validator\Constraints\Image;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'constraints' => [
                    new Assert\NotBlank(['message' => '请输入姓名']),
                    new Assert\Length(['min' => 2]),
                ],
            ])
            ->add('avatar', FileType::class, [
                'mapped' => false,
                'constraints' => [
                    new File([
                        'maxSize' => '2M',
                        'mimeTypes' => ['image/jpeg', 'image/png'],
                        'mimeTypesMessage' => '请上传有效的图片文件',
                    ]),
                    new Image([
                        'minWidth' => 100,
                        'maxWidth' => 2000,
                        'minHeight' => 100,
                        'maxHeight' => 2000,
                    ]),
                ],
            ])
        ;
    }
}

3.2 回调约束 #

php
<?php

use Symfony\Component\Validator\Context\ExecutionContextInterface;

class User
{
    #[Assert\Callback]
    public function validate(ExecutionContextInterface $context): void
    {
        if ($this->age < 18 && $this->hasParentConsent === false) {
            $context->buildViolation('未成年人需要家长同意')
                ->atPath('hasParentConsent')
                ->addViolation();
        }
    }
}

3.3 类级别约束 #

php
<?php

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

#[Assert\Callback('validateDates')]
class Event
{
    private ?\DateTimeInterface $startDate = null;
    private ?\DateTimeInterface $endDate = null;

    public static function validateDates(self $event, ExecutionContextInterface $context): void
    {
        if ($event->startDate && $event->endDate) {
            if ($event->startDate >= $event->endDate) {
                $context->buildViolation('结束日期必须晚于开始日期')
                    ->atPath('endDate')
                    ->addViolation();
            }
        }
    }
}

四、自定义约束 #

4.1 创建约束类 #

php
<?php

namespace App\Validator\Constraints;

use Attribute;
use Symfony\Component\Validator\Constraint;

#[Attribute]
class ContainsAlphanumeric extends Constraint
{
    public string $message = '字符串"{{ string }}"包含非法字符,只能包含字母和数字。';

    public function validatedBy(): string
    {
        return static::class . 'Validator';
    }
}

4.2 创建验证器 #

php
<?php

namespace App\Validator\Constraints;

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);
        }

        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        if (!preg_match('/^[a-zA-Z0-9]+$/', $value)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ string }}', $value)
                ->addViolation();
        }
    }
}

4.3 使用自定义约束 #

php
<?php

use App\Validator\Constraints\ContainsAlphanumeric;

class User
{
    #[ContainsAlphanumeric(message: '用户名只能包含字母和数字')]
    private ?string $username = null;
}

五、验证组 #

5.1 定义验证组 #

php
<?php

class User
{
    #[Assert\NotBlank(groups: ['registration', 'profile'])]
    private ?string $name = null;

    #[Assert\NotBlank(groups: ['registration'])]
    #[Assert\Email(groups: ['registration', 'profile'])]
    private ?string $email = null;

    #[Assert\NotBlank(groups: ['registration'])]
    #[Assert\Length(min: 8, groups: ['registration'])]
    private ?string $password = null;

    #[Assert\NotBlank(groups: ['profile'])]
    private ?string $bio = null;
}

5.2 使用验证组 #

php
<?php

public function register(Request $request): Response
{
    $user = new User();
    
    $form = $this->createForm(UserType::class, $user, [
        'validation_groups' => ['registration'],
    ]);
    
    $form->handleRequest($request);
    
    if ($form->isSubmitted() && $form->isValid()) {
        // 处理注册
    }
}

public function profile(Request $request): Response
{
    $user = $this->getUser();
    
    $form = $this->createForm(UserType::class, $user, [
        'validation_groups' => ['profile'],
    ]);
    
    $form->handleRequest($request);
    
    if ($form->isSubmitted() && $form->isValid()) {
        // 处理资料更新
    }
}

5.3 动态验证组 #

php
<?php

use Symfony\Component\OptionsResolver\OptionsResolver;

class UserType extends AbstractType
{
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'validation_groups' => function (FormInterface $form) {
                $user = $form->getData();
                
                if ($user->getId()) {
                    return ['profile'];
                }
                
                return ['registration'];
            },
        ]);
    }
}

六、错误处理 #

6.1 获取表单错误 #

php
<?php

public function submit(Request $request): Response
{
    $form = $this->createForm(UserType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && !$form->isValid()) {
        $errors = [];
        
        foreach ($form->getErrors(true) as $error) {
            $errors[$error->getOrigin()->getName()] = $error->getMessage();
        }
        
        return $this->json(['errors' => $errors], 400);
    }
}

6.2 模板中显示错误 #

twig
{# 显示全局错误 #}
{% if form.vars.errors|length > 0 %}
    <div class="alert alert-danger">
        {% for error in form.vars.errors %}
            <p>{{ error.message }}</p>
        {% endfor %}
    </div>
{% endif %}

{# 显示字段错误 #}
<div class="form-group">
    {{ form_label(form.email) }}
    {{ form_widget(form.email) }}
    {% if form.email.vars.errors|length > 0 %}
        <div class="invalid-feedback">
            {% for error in form.email.vars.errors %}
                {{ error.message }}
            {% endfor %}
        </div>
    {% endif %}
</div>

6.3 自定义错误消息 #

php
<?php

#[Assert\NotBlank(message: '用户名不能为空')]
private ?string $username = null;

#[Assert\Email(
    message: '{{ value }} 不是有效的邮箱地址',
    mode: 'strict'
)]
private ?string $email = null;

#[Assert\Length(
    min: 8,
    max: 50,
    minMessage: '密码至少需要{{ limit }}个字符',
    maxMessage: '密码最多{{ limit }}个字符',
    exactMessage: '密码必须正好{{ limit }}个字符'
)]
private ?string $password = null;

七、手动验证 #

7.1 验证实体 #

php
<?php

use Symfony\Component\Validator\Validator\ValidatorInterface;

class UserService
{
    public function __construct(
        private ValidatorInterface $validator
    ) {}

    public function validate(User $user): array
    {
        $errors = $this->validator->validate($user);
        
        $messages = [];
        foreach ($errors as $error) {
            $messages[$error->getPropertyPath()] = $error->getMessage();
        }
        
        return $messages;
    }

    public function isValid(User $user): bool
    {
        return count($this->validator->validate($user)) === 0;
    }

    public function validateGroup(User $user, string $group): array
    {
        $errors = $this->validator->validate($user, null, [$group]);
        
        return $errors;
    }
}

八、总结 #

本章学习了:

  • 验证组件概述
  • 实体约束定义
  • 表单字段约束
  • 自定义约束
  • 验证组
  • 错误处理
  • 手动验证

下一章将学习 服务基础

最后更新:2026-03-28