6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Symfony7.4の新機能AbstractFlowTypeでマルチステップフォームを作ってみた

6
Posted at

Symfony Advent Calendar 2025 の17日目の記事です🎄

タイトルの通り、Symfony7.4からAbstractFlowTypeを使って簡単にマルチステップフォームを作成できるようになったようなので、使ってみました🤶
その方法と感想をまとめます!

参考記事はこちら👇
https://symfony.com/blog/new-in-symfony-7-4-multi-step-forms

目次

  1. 動作環境
  2. 実装してみる!
    1. DTO
    2. FormType
    3. Controller
    4. Template
  3. 実際の動き
  4. 感想

動作環境

  • PHP 8.2.25
  • Symfony 7.4.2

実装してみる!

$ symfony new multi-step-form --version=7.4 webapp

Symfony7.4からAbstractFlowTypが使用可能となっています。

DTO

<?php
declare(strict_types=1);

namespace App\Form\Data;

use Symfony\Component\Validator\Constraints as Assert;

class MultiStepDto
{
    // Step 1:
    #[Assert\NotBlank(groups: ['step1'])]
    public ?string $username = null;

    #[Assert\NotBlank(groups: ['step1'])]
    #[Assert\Length(min: 3, groups: ['step1'])]
    #[Assert\Email(groups: ['step1'])]
    public ?string $email = null;

    // Step 2:
    #[Assert\NotBlank(groups: ['step2'])]
    public ?int $age = null;

    #[Assert\NotBlank(groups: ['step2'])]
    public ?string $pref = null;

    // Step 3:
    #[Assert\NotBlank(groups: ['step3'])]
    #[Assert\Length(min: 3, groups: ['step3'])]
    public ?string $freeText = null;

    #[Assert\IsTrue(groups: ['step3'])]
    public ?bool $cookieUse = false;

    // Current step value:
    public ?string $currentStep = null;
}

FormType

<?php
declare(strict_types=1);

namespace App\Form\Type;

use App\Form\Data\MultiStepDto;
use App\Form\Type\Step\Step1Type;
use App\Form\Type\Step\Step2Type;
use App\Form\Type\Step\Step3Type;
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\Form\Flow\Type\NavigatorFlowType;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MultiStepType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder->addStep('step1', Step1Type::class);
        $builder->addStep('step2', Step2Type::class);
        $builder->addStep('step3', Step3Type::class);

        $builder->add('navigator', OriginalNavigatorFlowType::class);
        
        // ボタン等がデフォルトのままで良い場合は以下でok
        // $builder->add('navigator', NavigatorFlowType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => MultiStepDto::class,
            'step_property_path' => 'currentStep',
            'translation_domain' => false,
        ]);
    }
}

(Step2Type, Step3Typeは省略してます)

<?php
declare(strict_types=1);

namespace App\Form\Type\Step;

use App\Form\Data\MultiStepDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class Step1Type extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('username')
            ->add('email')
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => MultiStepDto::class,
            'inherit_data' => true,
            'label' => 'step1',
        ]);
    }
}

ボタンのテキストを変更したり、装飾する場合はこんな感じ。

<?php

declare(strict_types=1);

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Flow\FormFlowCursor;
use Symfony\Component\Form\Flow\Type\FinishFlowType;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class OriginalNavigatorFlowType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('back', PreviousFlowType::class, [
                'label' => 'Back',
                'attr' => ['class' => 'btn btn-secondary'],
            ])
            ->add('next', NextFlowType::class, [
                'label' => 'Next',
                'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->getCurrentStep() !== 'documents' && $cursor->canMoveNext(),
                'attr' => ['class' => 'btn btn-primary'],
            ])
            ->add('finish', FinishFlowType::class, [
                'label' => 'Submit',
                'attr' => ['class' => 'btn btn-success'],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => false,
            'mapped' => false,
            'priority' => -100,
        ]);
    }
}

Controller

<?php
declare(strict_types=1);

namespace App\Controller;

use App\Form\Data\MultiStepDto;
use App\Form\Type\MultiStepType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Flow\FormFlowInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class MultiStepFormController extends AbstractController
{
    #[Route('/form', name: 'app_form')]
    public function __invoke(Request $request): Response
    {
        /** @var FormFlowInterface $flow */
        $flow = $this->createForm(MultiStepType::class, new MultiStepDto())
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            return $this->redirectToRoute('app_success');
        }

        return $this->render('form.html.twig', [
            'form' => $flow->getStepForm(),
        ], new Response(null, $flow->isSubmitted() ? 422 : 200));
    }

    #[Route('/success', name: 'app_success')]
    public function success(): Response
    {
        return new Response('Success!');
    }
}

Template

{% extends 'base.html.twig' %}

{% block body %}
    <div class="container">
        <h2>Multi Step Form</h2>
        <div class="row text-center align-items-center">
            {% for step in form.vars.visible_steps %}
                {% if not loop.first and not loop.last %}<div class="col"></div>{% endif %}
                <div class="col">
                    {% if step.is_current_step %}
                        🔵
                    {% elseif step.index > form.vars.cursor.stepIndex %}
                        ⚪️
                    {% else %}
                        ☑️
                    {% endif %}
                    <div>
                        {{ step.name|title }}
                    </div>
                </div>
                {% if not loop.first and not loop.last %}<div class="col"></div>{% endif %}
            {% endfor %}
        </div>
        
        <div>
            {{ form(form) }}  //基本、これだけでok
        </div>
    </div>
{% endblock %}

実際の動き

画面収録 2025-12-18 12.37.46 (1).gif

感想

詰まったところとしては、Turbo Driveを使ったフォーム送信のようで、
data-turbo="false"にするか、以下のようにしないと次のステップへ進めない事象がありました。
ただSymfony6.2以降では以下のコード変更は不要とのことだったので、原因追及中です🤔

         return $this->render('form.html.twig', [
            'form' => $flow->getStepForm(),
-        ]);
+        ], new Response(null, $flow->isSubmitted() ? 422 : 200));

参考記事:
http://symfony.com/bundles/ux-turbo/current/index.html#3-form-response-code-changes

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?