You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Symfony动态下拉表单提交验证失败:所选选项无效

Symfony动态表单提交验证错误:The selected choice is invalid

问题描述

开发Symfony项目时,实现了基于用户选择动态填充下拉框的功能:选择品牌后更新对应型号下拉选项。采用AJAX处理动态更新,并用PRE_SUBMIT事件在提交前修改表单,但提交表单时Symfony验证失败,抛出错误:The selected choice is invalid。

代码详情

AnnonceFormType

public function buildForm(FormBuilderInterface $builder, array $options): void
{
    $builder
        ->add('category', ChoiceType::class, [
            'label' => $this->translator->trans('Sélectioner la catégorie *'),
            'choices' => [
                $this->translator->trans('Auto') => $this->translator->trans('auto'),
                $this->translator->trans('Moto') => $this->translator->trans('moto'),
            ],
            'expanded' => true,
            'multiple' => false,
            'attr' => [
                'class' => 'form-check-input',
                'name' => 'category',
            ],
            
        ])
        ->add('marques', ChoiceType::class, [
            'label' => false,
            'choices' => [],
            'placeholder' => $this->translator->trans('Sélectionner la marque'),
        ])
        ->add('modeles', ChoiceType::class, [
            'label' => false,
            'choices' => [],
            'placeholder' => $this->translator->trans('Sélectionner le modéle'),
            
    ]);

    $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
        $data = $event->getData();
        $form = $event->getForm();

        if (isset($data['category'])) {
            $marques = $this->getMarques($data['category']);
            $form->add('marques', ChoiceType::class, [
                'choices' => array_combine(array_map(function($marque) {
                    return $marque;
                }, $marques), array_map(function($marque) {
                    return $marque;
                }, $marques)),
                'choice_label' => function ($marque) {
                    return $marque; // Afficher le titre
                },
                'choice_value' => function ($marque) {
                    return $marque; // Persister le titre
                },
                'placeholder' => $this->translator->trans('Sélectionner une marque'),
                'label' => false,
            ]);
        }

        if (isset($data['marques'])) {
            dump($data['marques']);
            $marqueId = array_search($data['marques'], array_flip($this->getMarques($data['category'])));
            dump($marqueId);
            if($marqueId !== false){
                $modeles = $this->getModeles($data['marques']);
                $form->add('modeles', ChoiceType::class, [
                    'choices' => array_combine(array_map(function($modele) {
                        return $modele;
                    }, $modeles), array_map(function($modele) {
                        return $modele;
                    }, $modeles)),
                    'choice_label' => function ($modele) {
                        return $modele; 
                    },
                    'choice_value' => function ($modele) {
                        return $modele; 
                    },
                    'placeholder' => $this->translator->trans('Sélectionner un modèle'),
                    'label' => false,
                ]);
            }
            
        }
    });
}

private function getMarques(?string $category): array
{
    if (!$category) {
        return [];
    }
    $marques = $this->marqueRepository->findMarques($category);
    $choices = [];
    foreach ($marques as $marque) {
        $choices[$marque->getId()] = $marque->getTitle(); 
    }
    return $choices;
}

private function getModeles(?int $marqueId): array
{
    if (!$marqueId) {
        return [];
    }
    $modeles = $this->modeleRepository->findModeles($marqueId);
    $choices = [];
    foreach ($modeles as $modele) {
        $choices[$modele->getId()] = $modele->getTitleModele(); 
    }
    return $choices;
}

控制器

#[Route('/{_locale}/getmarques/{type}', name: 'app_getmarque')]
public function getMarques(string $type)
{
    $marques = $this->marqueRepository->findMarques($type);

    dump($marques);
    return $this->render('addannonce/getmarques.html.twig', [
        'marques' => $marques,
    ]);
}

#[Route('/{_locale}/getmodeles/{marque}', name: 'app_getmodele')]
public function getModeles(int $marque)
{
    dump($marque);
    $modeles = $this->modeleRepository->findModeles($marque);
    dump($modeles);    
    return $this->render('addannonce/getmodeles.html.twig', [
        'modeles' => $modeles,
    ]);
}

AJAX代码

if (jQuery) {
  // Fonction pour récupérer les marques en fonction du type sélectionné
  function getMarques(type) {
    let path = $("div.s-submit").data("path-getmarque");

    if (path) {
      path = path.replace("__type__", type);

      $.ajax({
        url: path,
        dataType: "html",
        type: "get",
        cache: false,
        beforeSend: function () {
          // Affiche un indicateur de chargement
          $("#AnnonceType1_marques").html("<option>Chargement des données...</option>");
        },
        success: function (data) {
          console.log("getMarques", data);
          $("#AnnonceType1_marques").html(data);
        },
        error: function (xhr, status, error) {
          console.log("Erreur AJAX:", status, error);
          alert(
            "Une erreur est survenue lors de la récupération des marques. Veuillez réessayer."
          );
        },
      });
    } else {
      console.log("Erreur: attribut de données non trouvé");
    }
  }

  // Lorsque la sélection de marque change, récupérer les modèles correspondants
  $("#AnnonceType1_marques").on("change", function () {
    let marqueId = $(this).val();
    let marqueText = $(this).find("option:selected").text();

    console.log("Marque sélectionnée:", marqueText);
    console.log("ID de la marque sélectionnée:", marqueId);

    if (marqueId) {
      let path = $("div.s-submit").data("path-getmodele");

      if (path) {
        path = path.replace("__id__", marqueId);

        $.ajax({
          url: path,
          data: { marque: marqueId },
          dataType: "html",
          type: "get",
          cache: false,
          beforeSend: function () {
            // Affiche un indicateur de chargement
            $("#AnnonceType1_modeles").html("<option>Chargement des données...</option>");
          },        
          success: function (data) {
            console.log("getModeles", data);
            $("#AnnonceType1_modeles").html(data);
          },
          error: function (xhr, status, error) {
            console.log("Erreur AJAX:", status, error);
            alert(
              "Une erreur est survenue lors de la récupération des modèles. Veuillez réessayer."
            );
          },
        });
      }
    } else {
      $("#AnnonceType1_modeles").html(
        '<option value="">Sélectionner la marque</option>'
      );
    }
  });

  // Log du modèle sélectionné
  $("#AnnonceType1_modeles").on("change", function () {
    let modeleId = $(this).val();
    let modeleTitle = $(this).find("option:selected").text();

    console.log("ID du modèle sélectionné:", modeleId);
    console.log("Texte du modèle sélectionné:", modeleTitle);
  });
} else {
  console.log("Erreur: jQuery n'est pas chargé");
}

模板文件

{# getmarques.html.twig #}
{% if(marques) %}
    <option value="">--{{"Sélectionner"|trans }}--</option>
    {% for marque in marques %}
        <option value="{{ marque.id}}">{{ marque.title }}</option>
    {% endfor %}
{% else %}
    {{"pas de marque"|trans }}
{% endif %}

{# getmodeles.html.twig #}
{% if(modeles) %}
    <option value="">--{{"Sélectionner"|trans }}--</option>
    {% for modele in modeles %}
        <option value="{{ modele.id }}">
            {{ modele.titleModele }}
        </option>
    {% endfor %}
{% else %}
    {{"pas de modéle"|trans }}
{% endif %}

已尝试方案

  • 使用AJAX在用户选择类型/品牌时动态获取对应品牌和型号
  • 添加PRE_SUBMIT事件监听器,在提交前根据提交数据更新表单选项
  • 在PRE_SUBMIT事件中重新配置ChoiceType字段的选项

预期效果

表单可根据所选品牌动态更新型号,提交品牌和型号的标题(而非ID),数据能正确持久化到数据库且无验证错误。

解决方案

问题根源

前端提交的是品牌/型号的ID,但PRE_SUBMIT事件中配置的ChoiceType选项把标题作为choice_value,导致Symfony验证时无法匹配提交的ID,抛出"The selected choice is invalid"错误。同时,代码中对choices的处理(array_combine将标题同时作为键和值)完全偏离了getMarques/getModeles返回的[ID => 标题]结构,进一步加剧了不匹配。

修正步骤

1. 修正PRE_SUBMIT事件中的表单配置

保持choices为getMarques/getModeles返回的[ID => 标题]结构,让choice_value默认使用ID(与前端提交值一致),choice_label默认使用标题,同时将提交的ID转换为标题:

$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
    $data = $event->getData();
    $form = $event->getForm();

    // 更新品牌选项
    if (isset($data['category'])) {
        $marques = $this->getMarques($data['category']);
        $form->add('marques', ChoiceType::class, [
            'choices' => $marques,
            'placeholder' => $this->translator->trans('Sélectionner une marque'),
            'label' => false,
        ]);
    }

    // 更新型号选项,并将提交的ID转换为标题
    if (isset($data['marques']) && isset($data['category'])) {
        $marqueId = (int)$data['marques'];
        $marques = $this->getMarques($data['category']);

        // 验证品牌ID是否有效
        if (isset($marques[$marqueId])) {
            // 将提交的品牌ID替换为标题
            $data['marques'] = $marques[$marqueId];
            $event->setData($data);

            // 获取对应型号并更新表单选项
            $modeles = $this->getModeles($marqueId);
            $form->add('modeles', ChoiceType::class, [
                'choices' => $modeles,
                'placeholder' => $this->translator->trans('Sélectionner un modèle'),
                'label' => false,
            ]);

            // 如果有型号提交,同样将ID转换为标题
            if (isset($data['modeles'])) {
                $modeleId = (int)$data['modeles'];
                if (isset($modeles[$modeleId])) {
                    $data['modeles'] = $modeles[$modeleId];
                    $event->setData($data);
                }
            }
        }
    }
});

2. 保留初始表单字段配置

初始的marques和modeles字段无需额外修改,保持空choices即可:

->add('marques', ChoiceType::class, [
    'label' => false,
    'choices' => [],
    'placeholder' => $this->translator->trans('Sélectionner la marque'),
])
->add('modeles', ChoiceType::class, [
    'label' => false,
    'choices' => [],
    'placeholder' => $this->translator->trans('Sélectionner le modéle'),
])

修正逻辑说明

  • 前端提交的ID与表单选项的choice_value(ID)匹配,验证通过
  • 在PRE_SUBMIT事件中将提交的ID转换为对应标题,更新提交数据,满足持久化标题的需求
  • 直接复用getMarques/getModeles返回的结构,避免不必要的数组操作,减少出错概率

内容的提问来源于stack exchange,提问作者kasali

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 01:57:31