用表单事件动态调整 Symfony 表单

表单事件可以根据已有实体、提交的数据或外部服务,动态调整表单字段。下面依次给出常见场景及对应代码。初次接触这一机制时,先阅读 表单事件文档,了解各事件发生的时间和可用的数据。

只在创建实体时显示字段

新建实体和编辑实体往往需要不同字段。例如,注册时允许填写用户名、创建产品时允许填写 SKU,之后则禁止修改。在 PRE_SET_DATA 中判断实体是否已有 ID,就能区分已持久化的对象和新对象。

// src/Form/ProductType.php
namespace App\Form;

use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('description')
            ->add('price')
        ;

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (PreSetDataEvent $event): void {
                $product = $event->getData();
                $form = $event->getForm();

                // product is new if there's no data or no ID
                $isNew = !$product || null === $product->getId();

                if ($isNew) {
                    // SKU can only be set when creating, not editing
                    $form->add('sku', TextType::class, [
                        'help' => 'Cannot be changed after creation',
                    ]);
                }
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Product::class,
        ]);
    }
}

对于简单场景,也可以把判断结果作为表单选项传入:

$form = $this->createForm(ProductType::class, $product, [
    'is_new' => null === $product->getId(),
]);

// then in buildForm(), check $options['is_new']

表单选项更简单,但事件的灵活性更高。这里的选项示例还需要在表单类型的 configureOptions() 中声明 is_new;它并非可独立运行的完整表单。

联动选择框:国家与州或省

联动选择框的选项取决于另一个字段的值。国家与州、省的组合是典型例子。实现时必须同时处理两个阶段:首次显示时根据实体当前的国家填充州或省;提交时根据用户新提交的国家更新对应选项。

// src/Form/AddressType.php
namespace App\Form;

use App\Entity\Address;
use App\Entity\Country;
use App\Entity\State;
use App\Repository\StateRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PostSetDataEvent;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class AddressType extends AbstractType
{
    public function __construct(
        private StateRepository $stateRepository,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('street')
            ->add('city')
            ->add('country', EntityType::class, [
                'class' => Country::class,
                'choice_label' => 'name',
                'placeholder' => 'Select a country',
            ])
        ;

        // this closure adds the State field with appropriate choices
        $addStateField = function (FormInterface $form, ?Country $country): void {
            $states = null === $country
                ? []
                : $this->stateRepository->findByCountry($country);

            $form->add('state', EntityType::class, [
                'class' => State::class,
                'choices' => $states,
                'choice_label' => 'name',
                'placeholder' => null === $country
                    ? 'Select country first'
                    : ([] === $states ? 'No states available' : 'Select a state'),
            ]);
        };

        // 1) handle initial render: add State based on entity's Country
        $builder->addEventListener(
            FormEvents::POST_SET_DATA,
            function (PostSetDataEvent $event) use ($addStateField): void {
                $address = $event->getData();
                $country = $address?->getCountry();

                $addStateField($event->getForm(), $country);
            }
        );

        // 2) handle submission: update State when Country changes:
        // listen to Country field's POST_SUBMIT (after Country is processed)
        $builder->get('country')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (PostSubmitEvent $event) use ($addStateField): void {
                // get the selected Country entity (not the raw ID)
                $country = $event->getForm()->getData();

                // add State field to the PARENT form
                $addStateField($event->getForm()->getParent(), $country);
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Address::class,
        ]);
    }
}

关键是监听子字段 country 的 POST_SUBMIT,然后修改父表单中的 state。此时不能修改触发事件的子表单本身,但可以修改其父表单。子字段完成处理后,取到的是 Country 实体,而不是请求中的原始 ID。

用 JavaScript 即时更新选项

上面的 PHP 代码负责提交处理。为了让用户改变国家后立即看到新的州、省选项,可以补充以下模板及脚本:

{# templates/address/form.html.twig #}
{{ form_start(form, {attr: {id: 'address-form'}}) }}
    {{ form_row(form.street) }}
    {{ form_row(form.city) }}
    {{ form_row(form.country) }}
    {{ form_row(form.state) }}
{{ form_end(form) }}

<script>
const form = document.getElementById('address-form');
const countrySelect = document.getElementById('address_country');
const stateSelect = document.getElementById('address_state');

countrySelect.addEventListener('change', async function() {
    // submit just the country field to get updated state options
    const formData = new FormData();
    formData.append(this.name, this.value);

    const response = await fetch(form.action, {
        method: form.method,
        body: new URLSearchParams(formData),
    });

    // parse the response HTML and extract the new state options
    const html = await response.text();
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    const newStateSelect = doc.getElementById('address_state');

    stateSelect.innerHTML = newStateSelect.innerHTML;
});
</script>

脚本复用服务器已有的表单处理逻辑,不另建端点。若希望少写定制 JavaScript,可以考虑 Symfony UX Live Component。

实现补充:该教学脚本只发送国家字段,未处理 CSRF 字段、请求失败、响应中找不到目标选择框或连续请求的先后顺序。应用中应结合控制器的表单处理流程补齐这些条件,不能直接把此片段视为生产环境的完整交互实现。

根据复选框显示附加字段

选中复选框时显示额外输入项,同样需要同时处理首次显示与提交:

// src/Form/OrderType.php
namespace App\Form;

use App\Entity\Order;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PostSetDataEvent;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotBlank;

class OrderType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('product')
            ->add('isGift', CheckboxType::class, [
                'required' => false,
                'label' => 'This is a gift',
            ])
        ;

        $addGiftFields = function (FormInterface $form, bool $isGift): void {
            if ($isGift) {
                $form->add('giftRecipient', TextType::class, [
                    'label' => 'Recipient name',
                    'constraints' => [new NotBlank()],
                ]);
                $form->add('giftMessage', TextareaType::class, [
                    'required' => false,
                    'label' => 'Gift message',
                ]);
            }
        };

        // 1) handle initial render
        $builder->addEventListener(
            FormEvents::POST_SET_DATA,
            function (PostSetDataEvent $event) use ($addGiftFields): void {
                $order = $event->getData();
                $isGift = $order?->isGift() ?? false;

                $addGiftFields($event->getForm(), $isGift);
            }
        );

        // 2) handle submission
        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (PreSubmitEvent $event) use ($addGiftFields): void {
                // raw submitted data (array of strings)
                $data = $event->getData();
                $isGift = isset($data['isGift']) && $data['isGift'];

                $addGiftFields($event->getForm(), $isGift);
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Order::class,
        ]);
    }
}

PRE_SUBMIT 读到的是原始请求数据数组;POST_SET_DATA 读到的是实体。未经转换的复选框值通常是字符串 "1",未勾选时可能没有该键,而不是 PHP 布尔值。

原文在这一场景建议用 POST_SET_DATA 调整结构,并用 PRE_SET_DATA 中的 $event->setData() 调整数据。这个建议不能理解为 PRE_SET_DATA 禁止增删字段:同篇的新实体示例就使用它增加字段,官方生命周期说明也列出了这一用途。应按事件时机、需要的数据及修改对象选择事件。

实现补充:$addGiftFields 在值为假时没有移除字段。如果编辑的订单最初已是礼物,而本次取消勾选,首次构建时添加的礼物字段可能仍然存在。应用需处理取消勾选这一分支,尤其要复核 NotBlank 的影响。

根据选择项添加不同字段

不同支付方式需要不同资料。使用 PRE_SUBMIT 可以在表单处理原始请求数据时,按选择值增加对应字段:

// src/Form/PaymentType.php
namespace App\Form;

use App\Entity\Payment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class PaymentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('method', ChoiceType::class, [
            'choices' => [
                'Credit Card' => 'card',
                'Bank Transfer' => 'bank',
                'PayPal' => 'paypal',
            ],
            'placeholder' => 'Choose payment method',
        ]);

        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (PreSubmitEvent $event): void {
                $data = $event->getData();
                $form = $event->getForm();
                $method = $data['method'] ?? null;

                match ($method) {
                    'card' => $form
                        ->add('cardNumber', TextType::class, [
                            'constraints' => [
                                new Assert\NotBlank(),
                                new Assert\Luhn(), // checks the credit card number
                            ],
                        ])
                        ->add('cardExpiry', TextType::class, [
                            'constraints' => [new Assert\NotBlank()],
                        ]),
                    'bank' => $form->add('iban', TextType::class, [
                        'constraints' => [
                            new Assert\NotBlank(),
                            new Assert\Iban(),
                        ],
                    ]),
                    'paypal' => $form->add('paypalEmail', TextType::class, [
                        'constraints' => [new Assert\NotBlank()],
                    ]),
                    default => null,
                };
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Payment::class,
        ]);
    }
}

如果字段相同,只是验证规则随某个值改变,可以使用 When 约束。这里的 Luhn 校验只检查号码格式,不能证明卡片有效或完成付款。

预处理提交的数据

在表单正式处理输入之前,使用 PRE_SUBMIT 统一输入格式。示例把电话号码保留为数字、去除邮箱两端空格并转为小写,把消息中的连续空白合并。

// src/Form/ContactType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;

class ContactType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('email', EmailType::class)
            ->add('phone', TelType::class, ['required' => false])
            ->add('message', TextareaType::class)
        ;

        $builder->addEventListener(
            FormEvents::PRE_SUBMIT,
            function (PreSubmitEvent $event): void {
                $data = $event->getData();

                // normalize phone: keep only digits
                if (!empty($data['phone'])) {
                    $data['phone'] = preg_replace('/\D/', '', $data['phone']);
                }

                // normalize email: lowercase
                if (!empty($data['email'])) {
                    $data['email'] = strtolower(trim($data['email']));
                }

                // clean message: normalize whitespace
                if (!empty($data['message'])) {
                    $data['message'] = preg_replace('/\s+/', ' ', trim($data['message']));
                }

                $event->setData($data);
            }
        );
    }
}

这些转换会改变输入的含义,例如电话号码的加号与分机信息会被删除、消息换行会变成空格。是否适用应由业务规则决定;这段规范化逻辑不等于通用安全过滤。

用外部服务填充字段

表单构建时可以调用 API 或服务取得选项。下面在 PRE_SET_DATA 中,根据实体现有的邮政编码取得城市列表:

// src/Form/ProfileType.php
namespace App\Form;

use App\Entity\Profile;
use App\Service\GeocodingService;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProfileType extends AbstractType
{
    public function __construct(
        private GeocodingService $geocoding,
    ) {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class)
            ->add('postalCode', TextType::class)
        ;

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (PreSetDataEvent $event): void {
                $profile = $event->getData();
                $form = $event->getForm();

                // fetch cities based on current postal code
                $postalCode = $profile?->getPostalCode();
                $cities = $postalCode
                    ? $this->geocoding->getCitiesForPostalCode($postalCode)
                    : [];

                $form->add('city', ChoiceType::class, [
                    'choices' => array_combine($cities, $cities),
                    'placeholder' => $cities ? 'Select a city' : 'Enter postal code first',
                ]);
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Profile::class,
        ]);
    }
}

外部服务调用可能在每次显示和提交时执行,要注意性能、缓存及失败处理。此示例根据已有数据构建城市字段;若要响应新提交的邮政编码,还需另外处理提交或联动更新。

创建可复用的事件订阅器

多个表单共用相同事件逻辑时,可以将它放进事件订阅器。以下订阅器为有时间戳的实体添加禁用的创建时间、修改时间字段:

// src/Form/EventSubscriber/TimestampFieldsSubscriber.php
namespace App\Form\EventSubscriber;

use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\FormEvents;

/**
 * Adds read-only createdAt/updatedAt fields for entities with timestamps.
 *
 * The #[Exclude] attribute prevents this class from being registered as a
 * service in the container. Form event subscribers should only be attached
 * to forms (via addEventSubscriber()), not the kernel event dispatcher.
 */
#[Exclude]
class TimestampFieldsSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            FormEvents::PRE_SET_DATA => 'addTimestampFields',
        ];
    }

    public function addTimestampFields(PreSetDataEvent $event): void
    {
        $entity = $event->getData();
        $form = $event->getForm();

        // skip for new entities
        if (!$entity || !method_exists($entity, 'getCreatedAt')) {
            return;
        }

        if (null !== $entity->getCreatedAt()) {
            $form->add('createdAt', DateTimeType::class, [
                'disabled' => true,
                'label' => 'Created',
            ]);
        }

        if (method_exists($entity, 'getUpdatedAt') && null !== $entity->getUpdatedAt()) {
            $form->add('updatedAt', DateTimeType::class, [
                'disabled' => true,
                'label' => 'Last modified',
            ]);
        }
    }
}

通过 addEventSubscriber() 将它附加到具体表单:

use App\Form\EventSubscriber\TimestampFieldsSubscriber;

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('title')
        ->add('content')
        // ...
        ->addEventSubscriber(new TimestampFieldsSubscriber())
    ;
}

#[Exclude] 避免该类自动注册进服务容器,从而防止它被自动配置为内核事件订阅器。表单订阅器应附加到表单。

订阅器需要依赖时,把依赖注入表单类型,再传给订阅器。这样订阅器仍可保持从容器中排除:

// src/Form/EventSubscriber/AuditFieldsSubscriber.php
namespace App\Form\EventSubscriber;

use App\Repository\UserRepository;
use Symfony\Component\DependencyInjection\Attribute\Exclude;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\FormEvents;

#[Exclude]
class AuditFieldsSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private UserRepository $userRepository,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [FormEvents::PRE_SET_DATA => 'addAuditFields'];
    }

    public function addAuditFields(PreSetDataEvent $event): void
    {
        // use $this->userRepository to fetch user names for audit display
        // ...
    }
}

表单类型取得依赖后实例化订阅器:

public function __construct(
    private UserRepository $userRepository,
) {
}

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        // ...
        ->addEventSubscriber(new AuditFieldsSubscriber($this->userRepository))
    ;
}

审查范围:代码保留自官方文档,并完成事件时机与控制流的静态核对;未在 PHP 应用中运行,省略号和业务实体、仓储、服务的实现仍需由项目提供。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容