SymfonyFormで郵便番号や電話番号の分割フィールド

こんなよくありそうな入力フォーム
![スクリーンショット 2021-09-23 23.39.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2057677/c66dbfcc-c91c-24b6-49af-5beabb4de573.png)

想定するEntityは以下の様

```
　　　　/**
　　　　 * @ORM\Column(type="string", length=8, nullable=true)
　　　　 */
　　　　private $postal;
```

つまりEntityでは`$postal`の文字列のみで、入力フォームは分割したい。
意外と探しても情報がなかったのでできた方法をメモする

# 郵便番号用のFormTypeを作成

```
<?php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

class PostalType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('postal1', TextType::class, [
                "label" => false,
                "mapped" => false,
                "attr" => [
                    "class" => "p-postal-code",
                    "maxlength" => 3,
                ],
                "constraints" => [
                    new NotBlank([
                        "message" => "郵便番号が未入力です"
                    ]),
                    new Length([
                        "max" => 3,
                        "maxMessage" => "郵便番号が文字数オーバーです"
                    ])
                ]
            ])
            ->add('postal2', TextType::class, [
                "label" => false,
                "mapped" => false,
                "attr" => [
                    "class" => "p-postal-code",
                    "maxlength" => 4,
                ],
                "constraints" => [
                    new NotBlank([
                        "message" => "郵便番号が未入力です"
                    ]),
                    new Length([
                        "max" => 4,
                        "maxMessage" => "郵便番号が文字数オーバーです"
                    ])
                ]
            ])
        ;
        $builder
            ->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) use ($builder) {
                if(!is_array($event->getData())) return null;
                $parent = $event->getForm()->getParent();
                $parent->get($builder->getName())->setData(
                    implode("-", $event->getData())
                );
            })
            ->addModelTransformer(new CallbackTransformer(
                function ($var) {
                    if(null === $var) return [];
                    $exploded = explode("-", $var);
                    if(!isset($exploded[1])) {
                        return [];
                    }
                    return [
                        "postal1" => $exploded[0],
                        "postal2" => $exploded[1]
                    ];
                },
                function ($var) {
                    return implode("-", $var);
                }
            ))
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
        ]);
    }
}
```

# 利用側FormType

```
<?php

namespace App\Form;

use App\Entity\Inquiry;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class InquiryType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('postal', PostalType::class, [
                "parent_name" => "postal",
                "label" => "郵便番号",
            ])
        ;
    }
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Inquiry::class,
        ]);
    }
}
```

# ControllerとTwig
Controllerは普通に

```
public function form() {
    return $this->render('inquiry/form.html.twig, [
        "form" => $this->createForm(InquiryType::class, new Inquiry())->createView()
    ]);
}
```

Twigではこんな感じ

```
<div>
  {{ form_label(form.postal) }}
  {{ form_widget(form.postal.postal1) }}-{{ form_widget(form.postal.postal2) }}
</div>
```

# PostalTypeのイベントとDataTransformer

これらを使ってなんとかできた

```
            ->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) use ($builder) {
                if(!is_array($event->getData())) return null; // 最初のアクセス時など初期データが無い場合の対応
                $parent = $event->getForm()->getParent(); // 利用側にデータを渡す
                $parent->get($builder->getName())->setData(
                    implode("-", $event->getData())
                );
            })
```

イベントリスナーで`PRE_SUBMIT`のイベントで`PostalType`のデータを親Typeに結合して渡している

```
            ->addModelTransformer(new CallbackTransformer(
                // Entityのデータが文字列で渡される
                function ($var) {
                    if(null === $var) return []; // データない時とかは空配列を返しておく
                    $exploded = explode("-", $var); // ハイフンで分割
                    if(!isset($exploded[1])) {
                        return []; // データがおかしい時とかは空配列を返しておく
                    }
　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　　// PostalTypeのフィールド名に差し替えて返す
                    return [
                        "postal1" => $exploded[0],
                        "postal2" => $exploded[1]
                    ];
                },
                // フォームデータが配列で渡されるので結合して返す
                function ($var) {
                    return implode("-", $var);
                }
            ))
        ;
```

このデータトランスフォーマーでEntityの単一文字列とフォームで使うデータ配列を変換する。

# まとめ

こんな面倒くさいことしないといかんのか。もっとスマートな方法がありそうな気がするけど教えて偉い人。