Symfony FormFlow multi-step form

A six-step application wizard — Personal → Address → Employment → Preferences → Documents → Confirm — built end-to-end with Symfony's FormFlow component, shadcn/ui, and Reactolith on the client. The source lives at examples/symfony-multistep-form/ in the Reactolith repo.

Every visual decision is centralised in a single Twig form theme (templates/form/shadcn_form_theme.html.twig); every interactive concern is a kebab-cased custom element that Reactolith hydrates into a React component on mount.

What it demonstrates

  • Every Symfony field type rendered through the same theme: TextType, EmailType, NumberType, IntegerType, ChoiceType (single, multi, expanded), CheckboxType, DateType, DateTimeType, TimeType, ColorType, RangeType, UrlType, PasswordType, FileType, CollectionType, HiddenType, UuidType, UlidType.
  • FormFlow step navigation rendered as a shadcn-styled progress indicator. Completed steps are real submit buttons named after FormFlow's PreviousFlowType — click one and the flow jumps straight to that step.
  • File uploads that survive back-navigation. A small adapter moves UploadedFile objects into a temp directory before FormFlow's session storage tries to serialise them, then surfaces them again as "already uploaded" chips on the way back.
  • CollectionType with React-managed prototype. Symfony's data-prototype HTML gets parsed into a DOM node and rendered through <ReactolithComponent> so newly-added rows hydrate exactly like server-rendered ones.
  • Dark-mode toggle that survives navigation, wired through localStorage with an inline boot script to avoid the light-mode flash.

Anatomy

1. The theme as the single styling source

The Symfony form theme emits kebab-cased custom elements instead of native ones — <ui-input>, <ui-select>, <ui-checkbox>, etc. Reactolith resolves them at hydration time to React components in assets/components/ui/:

{# templates/form/shadcn_form_theme.html.twig #} {%- block form_widget_simple -%} {%- set _attrs = { name: full_name, id: id, type: type|default('text'), } -%} {%- if value is not empty %}{% set _attrs = _attrs|merge({'default-value': value}) %}{% endif -%} {%- if required %}{% set _attrs = _attrs|merge({required: 'required'}) %}{% endif -%} <ui-input{% for k, v in _attrs %} {{ k }}="{{ v }}"{% endfor %}></ui-input> {%- endblock -%}

The matching React component reads errors via useFormErrors so the same field can render a validation message without the theme having to know about state:

// assets/components/ui/input.tsx export function Input({ name, value, defaultValue, ...rest }: InputProps) { const errors = useFormErrors(name); const invalid = errors.length > 0; return ( <input name={name} defaultValue={value ?? defaultValue} aria-invalid={invalid || undefined} {...rest} /> ); }

2. Choices as data, not children

Reactolith wraps every DOM child in a ReactolithComponent before passing it to your component — which means a Select can't inspect its <option> children the way it would with raw JSX. The fix is to pass options as JSON instead:

{%- block choice_widget_collapsed -%} {%- set _options = [] -%} {%- for choiceValue, choice in choices -%} {%- set _options = _options|merge([{value: choice.value, label: choice.label}]) -%} {%- endfor -%} <ui-select name="{{ full_name }}" id="{{ id }}" json-value='{{ data|first|default('')|json_encode|e('html_attr') }}' json-options='{{ _options|json_encode|e('html_attr') }}' ></ui-select> {%- endblock -%}

The React component receives options as a plain array prop, renders a real popover with keyboard navigation and a search box, and writes a single <input type="hidden"> so the form data still matches the field's name.

3. FormFlow session storage and uploaded files

Symfony's SessionDataStorage serialises the model between steps. UploadedFile and even plain SplFileInfo block serialisation — so this example hooks the SUBMIT event on each file field to move the upload into a temp directory and then teaches the owning model how to (de)serialise file paths:

// src/Form/Step/DocumentsStepType.php $persistResume = static function (FormEvent $event) use (&$keepResume): void { $data = $event->getData(); $previous = $event->getForm()->getData(); if ($data instanceof UploadedFile) { $event->setData(self::moveToTemp($data)); return; } if (null === $data) { // User submitted no new file — restore the existing one unless // they explicitly removed it via the FileInput's X button. if ($keepResume && $previous instanceof File) { $event->setData($previous); } } }; $builder->get('resume')->addEventListener(FormEvents::SUBMIT, $persistResume); // src/Model/Documents.php public function __serialize(): array { return [ 'resume' => $this->resume?->getPathname(), 'portfolio' => array_map(fn (File $f) => $f->getPathname(), $this->portfolio), // … the rest stays plain data … ]; } public function __unserialize(array $data): void { $this->resume = isset($data['resume']) && is_file($data['resume']) ? new File($data['resume']) : null; // … rehydrate the rest … }

4. Delete-on-the-client without losing it on the server

When a step is shown again after going back, the FileInput renders every previously-uploaded file as an "already uploaded" chip with its own hidden input named <field>_keep[]. Clicking the X drops the hidden input from the DOM; the parent form's PRE_SUBMIT reads the remaining keep list and reconciles it against the model's previous data.

// assets/components/ui/file-input.tsx (excerpt) {visibleExisting.map(({ file, i }) => ( <li key={`existing-${i}`}> <input type="hidden" name={keepName} value={String(i)} /> <span>{file.name} · {formatBytes(file.size)}</span> <button onClick={() => removeExisting(i)}>×</button> </li> ))}

5. CollectionType prototypes through Reactolith

Symfony's data-prototype attribute on a CollectionType is HTML — when you append it client-side, its custom-element children don't get hydrated by default. The example uses Reactolith's exported ReactolithComponent to render the parsed prototype DOM through the same loader pipeline as everything else:

// assets/components/app/collection.tsx const app = useApp(); const handleAdd = () => { const html = prototype.split(prototypeName).join(String(index)); const wrapper = document.createElement("div"); wrapper.innerHTML = html.trim(); const node = wrapper.firstElementChild; if (node) setAddedRows((cur) => [...cur, { key: `a${index}`, element: node }]); }; return ( <> {addedRows.map(({ key, element }) => ( <ReactolithComponent key={key} element={element} component={app.component} /> ))} </> );

6. Click-to-go-back step indicator

FormFlow's PreviousFlowType already accepts a step name as its view data — pass it to $flow->movePrevious(...) and you skip straight to that step. The progress component just renders each completed step as a real submit button named after the previous button's HTML name:

{isClickable && ( <button type="submit" name={previousName} value={step.name}> {circle}{label} </button> )}

The Twig template passes the previous button's full HTML name (e.g. application_flow[navigator][previous]) to <ui-progress> via a previous-name attribute. Reactolith's submit handler picks up the clicked event.submitter and adds name=value to the FormData payload, so FormFlow sees the exact button it expects.

Run it locally

git clone https://github.com/reactolith/reactolith.git cd reactolith/examples/symfony-multistep-form composer install npm install # Two processes — Vite for the assets, PHP for the form flow: npm run dev symfony serve # or: php -S 127.0.0.1:8000 -t public/

Visit http://127.0.0.1:8000/apply and walk through the wizard. Try filling out three steps, going back two via the progress indicator, uploading a PDF, removing it, replacing it, and toggling dark mode in the top-right corner.

Where to read next

The patterns surfaced here are all documented in their own right:

  • Forms — the <Form> component, useFormErrors and the round-trip morph.
  • Props — how the kebab-case attribute soup turns into typed props in your React components.
  • Slots — for component compositions beyond children.
  • Chunk preloading — Vite plugin that injects <link rel="modulepreload"> tags for the components a page actually renders.