symfony/form
Symfony Form lets you build, process, and reuse HTML forms in PHP with a flexible, component-based API. Handle validation, data mapping, and rendering with ease in any Symfony or standalone app.
symfony/form is a standalone component — you can use it without Symfony's framework, but nothing works until you assemble the factory and a few extensions yourself. In a Symfony app, symfony/framework-bundle wires all of this for you; in Laravel, you build it.
Install and bootstrap (framework-less):
composer require symfony/form symfony/validator symfony/http-foundation
use Symfony\Component\Form\Forms;
use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationExtension;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Validator\Validation;
$factory = Forms::createFormFactoryBuilder()
->addExtension(new HttpFoundationExtension())
->addExtension(new ValidatorExtension(Validation::createValidator()))
->getFormFactory();
If you are rendering HTML you also need symfony/twig-bridge and a Twig environment with FormExtension + the form theme registered (form_div_layout.html.twig). Without it, form_widget() throws "Unknown function" — the number one first-hour stumble.
Mind the version line. The 8.1 series is current (8.1.9 as of this writing). Everything documented below exists in 8.1; if you are pinned to 7.x by symfony/http-foundation, check each feature's "since" note before relying on it. FormFlow, for example, needs 8.1.x and specifically ≥ 8.1.9 (see Gotchas).
First use case: a contact form with server-side validation. Build it as a class, not inline, from day one:
class ContactType extends AbstractType
{
public function buildForm(FormBuilderInterface $b, array $options): void
{
$b->add('email', EmailType::class)
->add('message', TextareaType::class, ['constraints' => [new NotBlank(), new Length(max: 2000)]])
->add('send', SubmitType::class);
}
public function configureOptions(OptionsResolver $r): void
{
$r->setDefaults(['data_class' => ContactMessage::class]);
}
}
Where to look first:
AbstractType, FormBuilderInterface, FormEvents, DataTransformerInterface in the source; those four files explain 80% of behaviour.Laravel reality check: Laravel's own FormRequest + Blade covers simple CRUD with less ceremony. Reach for symfony/form when you need the same form definition reused across contexts (web controller, API endpoint, CLI importer, admin panel), or when you want the type/data-transformer/event model. That is where it earns its keep.
Type classes as the unit of reuse. One AbstractType per form, one AbstractType per reusable field (AddressType, MoneyRangeType). Compose with ->add('billing_address', AddressType::class). Inject services into the type's constructor (a repository for choice_loader, a slugger, an uploader) and register it with the form.type tag — in Laravel, autowire types through the container and register each with $factory->getType(...) indirectly by passing new MyType($dep) to $builder->add().
The handleRequest → isValid loop. Keep controllers dumb:
$form = $this->factory->create(ContactType::class, new ContactMessage());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData(); // valid, mapped object
// redirect after success
}
Bridge Laravel's request in one place — Laravel already ships symfony/http-foundation as an illuminate/http dependency:
$symfonyRequest = \Symfony\Component\HttpFoundation\Request::createFromBase($laravelRequest->duplicate());
$form->handleRequest($symfonyRequest);
Dynamic forms via events. The canonical pattern: listen to FormEvents::PRE_SUBMIT on the parent to add/replace a child field based on submitted data (country → city). Use PRE_SET_DATA to shape the initial form from the model. Use POST_SUBMIT to react to a value without touching mapping. Avoid PRE_SUBMIT for validation logic — validation belongs in constraints.
Collections. CollectionType::class with allow_add/allow_delete and entry_type. Remember by_reference => false plus an adder/remover pair on the entity, otherwise the collection won't update. Render prototype and clone it client-side.
Extensions for cross-cutting behaviour. A FormTypeExtension registered globally lets you apply a default (attr help text, a "required" star, a data-collector hook) to FormType::class everywhere. This is the intended extension point and far cleaner than subclassing every type.
Data transformers. When the model value and the view value differ (enum ↔ string, cents ↔ "12,50", DateTime ↔ Y-m-d), write a DataTransformerInterface and attach with addModelTransformer/addViewTransformer. Money, DateTime, Enum and the file upload transformer are all implementations of the same idea. Prefer them to empty_data callbacks. Enum guessing (8.1.9, #66861) now reaches private properties declared on parent classes — if you map an inherited enum property that is private in the base class, the automatic EnumType transform now resolves instead of falling through to a string/ChoiceType guess. Re-run your form tests after the bump if you had a manual transformer standing in for that case; it may now be redundant (or double-applied).
Multistep forms. FormFlow (7.4 / 8.0, symfony/form 8.1+ recommended) manages step navigation and session state instead of hand-rolling hidden step fields. Pin ≥ 8.1.9. 8.1.8 (#66164) fixed flow creation, step fallback, backward navigation and reset; 8.1.9 (#66776) adds validation of the current step's data — previously you could advance past a step whose fields were individually invalid. Rely on the flow's own validation rather than hand-rolled isValid() calls on sub-forms, and re-test your wizard's "can I proceed?" behaviour: steps that used to advance will now block. Earlier 8.1.x patches covered the "skipped last step still marked finished" case (8.0.0-RC3 / 7.4.0-RC3). If you tried FormFlow on 8.1.0–8.1.7 and gave up on the back button, retest on 8.1.9 before writing your own step machinery.
Testing. $form = $factory->create(...); $form->submit([...]); and assert on isValid(), getErrors(), getData(). No HTTP layer needed; this is a strong argument for the component over request-validated alternatives. If you extend Symfony's own ValidatorExtensionTrait in custom test helpers, note that 8.1.8 dropped the deprecated any() matcher there (#66283) — your trait subclasses keep working, but any test shim that stubbed the matcher will need adjusting.
Composer version lockstep. All symfony/* packages must share a major version. Laravel pins symfony/http-foundation to ^7.x on some release lines; if so, composer require symfony/form:^8 will fail or force an unintended framework upgrade. Check composer show symfony/http-foundation before choosing. Symfony 8 requires PHP ≥ 8.4 — a real constraint in a Laravel 11/12 app on 8.2.
handleRequest() before isValid(). isValid() throws LogicException on an unsubmitted form. Also: do not call handleRequest() twice in the same request, and call clearMissing => false only for PATCH-style partial updates.
Empty data is not null. A non-required, non-compound text field submits ''; compound forms submit []. If your data_class has a non-nullable typed property, mapping blows up. Fix with empty_data on the field or a nullable property. Same class of bug appears with CollectionType — empty collection submission requires empty_data => [] on the collection field.
Expanded + multiple ChoiceType with sparse indices. Partial submits produced array_flip() warnings in older patches; fixed in 8.1.0-BETA2. Same for EnumType choice_label with grouped choices — patches landed in 7.3.8/6.4.30.
Optional parents and required ChoiceType placeholders. If a required ChoiceType (placeholder included) sits inside an optional parent form, the placeholder used to be dropped from the rendered options when the parent was marked not-required. Fixed in 8.1.9 (#66647). If you had a 'placeholder' => ... silently not rendering in a nested/optional context, upgrade rather than adding an empty-string choice by hand.
CSRF in APIs and Livewire/Alpine posts. csrf_protection defaults to true. For stateless JSON endpoints set ['csrf_protection' => false] on the root type, or use csrf_token_id + csrf_field_name to align with Laravel's _token. Mismatched token IDs surface as "The CSRF token is invalid" with no further detail.
Validation needs ValidatorExtension. Without it, constraints on options are silently ignored — a nasty failure mode because the form still builds and submits. The duplicate-error bug when ValidatorExtension was instantiated more than once is fixed in 7.4.7 / 8.0.7 / 6.4.35; align patches accordingly.
Errors are attached shallowly. $form->getErrors() does not include child errors. Use getErrors(true) or iterate FormErrorIterator recursively when rendering a summary. getErrors(true, false) gives nested iterators by field — useful for mapping to a JSON:API-style error bag.
Debugging the pipeline. When a value "doesn't come through": check $form->getExtraData() (unmapped/unexpected fields), $form->getViewData() (post-view-transformer, i.e. the string), $form->getNormData() (post-model-transformer), and $form->getData(). Nine times out of ten the discrepancy is a transformer running twice or a wrong getParent().
Don't mutate data in listeners during submit. Editing event->getData() in POST_SUBMIT has no effect on mapping and confuses the profiler. Map in transformers, decide in POST_SUBMIT.
Performance. Resolving choices to objects on every request is the classic hot spot — use choice_loader with a lazy closure or an EntityType with query_builder. Also avoid PRE_SET_DATA listeners that eagerly add dozens of collection children; the fix in 7.4.9/8.0.9 for preserving children added by PRE_SET_DATA exists precisely because that pattern is common but fragile.
Newer options worth knowing. input => 'date_point' on DateType/TimeType/DateTimeType (7.4+) gives DateTimeImmutable instead of the default mutable object. 8.1 adds handle_missing_data to opt into MissingDataHandler for absent forms. CurrencyType gained active_at, not_active_at, legal_tender in the same release line — check those before maintaining your own currency list. Note that active_at => null previously broke CurrencyType; fixed in 8.1.9 (#66859). Explicitly passing null to mean "no reference date" is now a supported way to say "don't filter by active status", so prefer it to omitting the option when the value comes from config/customer record.
Translation churn. 8.1.8 and 8.1.9 are overwhelmingly translation releases: 8.1.8 reviewed validator strings across nl, sq, es, pl, sv, pt_BR, mk, ru, sr, id, ar, hr, it and fr; 8.1.9 continued with tl, bg, el, ur, ja, tr and fa, and fixed malformed placeholders (#66488) — a %name%/{{ value }} arity mismatch in a catalog can surface as an sprintf/trans error or a garbled message, so an upgrade can genuinely fix a crash, not just change wording. Harmless if you use the shipped catalogues — but if you have overridden validator strings in lang/ or matched on message text in assertions or error-mapping code, re-check those fixtures after the bump; wording changed in several locales. Prefer matching on message keys/constraint types rather than translated strings.
Session contamination. If you use FormFlow (session-backed) with entities holding non-serializable services, older patches leaked them into the session; fixed in 7.4.7/8.0.7. Store IDs in the flow, not entities. 8.1.9 (#66598) fixes FormFlow mutating session data written before 8.1 — flows persisted by a 7.x/8.0 deployment are now read and rewritten safely instead of being clobbered. If you are upgrading an app with live, in-flight wizard sessions, deploy 8.1.9 rather than 8.1.8, and still verify a half-finished flow after the deploy rather than assuming stored step data is laid out identically.
How can I help you explore Laravel packages today?