kachnitel / dynamic-form-bundle
Zero-configuration Symfony form generation from Doctrine entity metadata
Package info
github.com/kachnitel/dynamic-form-bundle
Type:symfony-bundle
pkg:composer/kachnitel/dynamic-form-bundle
Requires
- php: >=8.2
- doctrine/doctrine-bundle: ^3.0
- doctrine/orm: ^3.5
- symfony/doctrine-bridge: ^6.4|^7.0|^8.0
- symfony/form: ^6.4|^7.0|^8.0
- symfony/framework-bundle: ^6.4|^7.0|^8.0
- symfony/ux-autocomplete: ^3.0
- symfony/ux-live-component: ^2.13
- symfony/validator: ^6.4|^7.0|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.51
- marcocesarato/php-conventional-changelog: dev-main
- phpmd/phpmd: ^2.12
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.0
- rector/rector: ^2.5
- symfony/browser-kit: ^7.0|^8.0
- symfony/console: ^6.4|^7.0|^8.0
- symfony/intl: ^6.4|^7.0|^8.0
- symfony/yaml: ^6.4|^7.0|^8.0
Suggests
- symfony/intl: Required at runtime if any entity used with DynamicEntityFormType has #[Assert\Country], #[Assert\Language], #[Assert\Currency], or #[Assert\Locale] on a property — both for the corresponding CountryType/LanguageType/CurrencyType/LocaleType form widget to render its choice list, and for the constraint itself to validate at all (Symfony throws without it, independent of this bundle). See docs/TYPE_GUESSING.md.
README
Zero-configuration Symfony form generation from Doctrine entity metadata. Point DynamicEntityFormType at an entity and get a working create/edit form — scalar fields, associations, and Symfony UX LiveComponent collections included — without writing a FormType class.
Extracted from kachnitel/admin-bundle, where it serves as the auto-form engine behind the generic CRUD controller. Usable standalone in any Symfony + Doctrine application. License: MPL-2.0 (file-level copyleft, compatible with proprietary use — see License).
Quick Start
1. Install
composer require kachnitel/dynamic-form-bundle
Register in config/bundles.php:
Kachnitel\DynamicFormBundle\KachnitelDynamicFormBundle::class => ['all' => true],
No further configuration — the bundle has no config tree.
2. Build a form
use Kachnitel\DynamicFormBundle\Form\DynamicEntityFormType; $form = $this->createForm(DynamicEntityFormType::class, $product, [ 'entity_class' => Product::class, ]);
entity_class is the only required option. data_class defaults to entity_class, so binding the form straight to Product needs nothing further — pass data_class explicitly only when it should differ (a DTO, or null for an unmapped form). See Form Options for all options.
3. That's it
Every non-identifier scalar field and owning-side association on Product gets a form field, with type-appropriate widgets, validation, and nullability handling derived from Doctrine metadata. Doctrine string fields with a matching validator constraint (#[Assert\Email], #[Assert\Url], …) are upgraded to the corresponding widget automatically — see Type Guessing.
Form Options
| Option | Type | Default | Description |
|---|---|---|---|
entity_class |
string |
required | Fully-qualified entity class name to introspect |
data_class |
string|null |
entity_class |
The class submitted values are mapped onto. Lazily defaults to entity_class — only evaluated when omitted entirely. Pass explicitly to bind elsewhere, including null for an unmapped form bound to a plain array/DTO |
is_root |
bool |
true |
Set false for child forms inside LiveCollectionType to prevent collection associations from being re-added (avoids infinite recursion in bidirectional relationships) |
entity_instance |
object|null |
null |
The entity being edited. Forwarded to FieldEditabilityResolverInterface for per-row editability checks; pass a fresh new Entity() for create forms if your resolver needs an instance |
Supported Field Types
| Doctrine type | Form type | Widget options |
|---|---|---|
string |
TextType |
Upgraded by constraint or naming convention — see Type Guessing |
text |
TextareaType |
|
integer, smallint, bigint |
IntegerType |
|
decimal, float |
NumberType |
html5: true |
boolean |
CheckboxType |
Always required: false |
date, date_immutable |
DateType |
widget: single_text |
datetime, datetimetz, datetime_immutable, datetimetz_immutable |
DateTimeType |
widget: single_text |
time, time_immutable |
TimeType |
widget: single_text |
Backed PHP enum (via enumType) |
EnumType |
json, array, simple_array, object, blob, binary are silently skipped — no field, no error.
Nullability, empty_data, and required-field validation have non-obvious behaviour driven by Symfony transformer quirks. See Field Mapping for the full story.
Associations
| Doctrine type | Form type | UI |
|---|---|---|
ManyToOne, OneToOne (owning) |
EntityType |
Autocomplete dropdown |
ManyToMany (owning side) |
EntityType with multiple: true |
Autocomplete multi-select |
OneToMany |
LiveCollectionType with recursive DynamicEntityFormType |
Add / remove rows |
Two categories of association are automatically skipped — re-include them by having FieldEditabilityResolverInterface::isExplicitOverride() return true for that property:
- Inverse-side associations (
mappedByset) — exceptOneToManyin root forms, which is always kept - Single-valued associations with
inversedByset (a ManyToOne/OneToOne owning side pointing back at a parent's collection)
Default behaviour with
AlwaysEditableFieldResolver: bothcanEdit()andisExplicitOverride()returntrueunconditionally, so all associations — including the normally-skipped categories above — are included by default. The auto-skip rules only take effect once you bind a resolver that returnsfalseforisExplicitOverride()on associations it does not explicitly opt back in.
See Associations for the full auto-skip table, OneToMany cascade/orphanRemoval/adder-remover requirements, and troubleshooting.
Controlling Field Inclusion
interface FieldEditabilityResolverInterface { // General include/exclude gate. $entity is null when building a "new entity" form // or before LiveCollectionType binds a row — treat null as "include provisionally" // (DynamicFormEditabilityListener re-checks on PRE_SET_DATA once a real instance exists). public function canEdit(string $entityClass, string $property, ?object $entity = null): bool; // Opt back in to a structurally auto-skipped association (inverse side or parent back-reference). // Must return true ONLY for an explicit per-property override, never by entity-level default — // a permissive entity-wide default must not silently pull every back-reference into the form. public function isExplicitOverride(string $entityClass, string $property, ?object $entity = null): bool; }
Default binding: AlwaysEditableFieldResolver — both methods return true. Override in services.yaml:
Kachnitel\DynamicFormBundle\Editability\FieldEditabilityResolverInterface: alias: App\Form\MyFieldEditabilityResolver
See Editability for the full contract, the two-method design rationale, the PRE_SET_DATA listener and finishView() view filter, and a kachnitel/admin-bundle compiler-pass example.
Controlling Field Widgets
Doctrine string fields are upgraded from TextType to a more specific widget when a matching validator constraint is present — #[Assert\Email] → EmailType, #[Assert\Url] → UrlType, #[Assert\Country] → CountryType, etc. Powered by Symfony's own form.type_guesser.validator, wired automatically.
An optional naming-convention guesser (ConventionalFieldTypeGuesser, ships in this bundle) covers tel/color/search/email/url by field name for fields with no matching constraint. Not enabled by default — see Type Guessing for the opt-in recipe and how to write your own guesser.
How It Works
Four collaborating pieces:
| Class | Responsibility |
|---|---|
DoctrineFormTypeMapper |
Maps a single Doctrine field/association mapping to a Symfony form field config (type + options) |
DynamicEntityFormType |
Walks entity metadata, calls the mapper for each field/association, decides what to include |
FieldEditabilityResolverInterface |
Extension point — decides whether a property should be in the form at all |
FormTypeGuesserInterface (Symfony's own) |
Extension point — upgrades a string field to a more specific type than TextType |
DynamicEntityFormType has no knowledge of attributes, expressions, or permissions — every inclusion decision beyond Doctrine's structural rules (the identifier field, unsupported types) is delegated to the injected FieldEditabilityResolverInterface. The bundle ships a permissive default; consumers override the service alias.
DynamicFormEditabilityListener is a FormEvents::PRE_SET_DATA listener manually registered inside DynamicEntityFormType::buildForm() (not a DI-managed service — it needs the specific entity class and resolver instance from that build call). It re-runs canEdit() for every field once a real entity instance is bound, covering LiveCollectionType child forms where buildForm() runs before any row data is available. It can only remove fields already added by buildForm(); it never re-adds an association that was skipped at build time.
DynamicFormViewEditabilityFilter runs during finishView() and applies the same canEdit() check to the bound form data at FormView level. It specifically covers rows added dynamically by LiveCollectionType, removing rejected child views after those rows exist. It only removes existing view children and does not add fields skipped during form building.
Documentation
| Guide | Covers |
|---|---|
| Field Mapping | Full type table, nullability cross-check, empty_data transformer quirks, RequiredValueTransformer, duplicate-validation prevention |
| Associations | Collection mapping, cascade/orphanRemoval/adder-remover requirements, auto-skip rules, infinite-recursion prevention, troubleshooting |
| Editability | FieldEditabilityResolverInterface contract, two-method design rationale, DynamicFormEditabilityListener, kachnitel/admin-bundle real-world example |
| Type Guessing | Constraint-driven and naming-convention widget upgrades, FormTypeGuesserInterface extension point, kachnitel/admin-bundle opt-in recipe |
Development
composer test # phpstan (level 10) + phpcs + phpunit + phpmd composer phpstan composer phpunit composer phpmd
Run a specific group:
vendor/bin/phpunit --group auto-form # DynamicEntityFormType + DoctrineFormTypeMapper core vendor/bin/phpunit --group collections # Association collection handling vendor/bin/phpunit --group dynamic-form # DynamicEntityFormType + DynamicFormEditabilityListener vendor/bin/phpunit --group editability # AlwaysEditableFieldResolver vendor/bin/phpunit --group form-transformers vendor/bin/phpunit --group form-exceptions # NullabilityMismatchException vendor/bin/phpunit --group inline-add # data-admin-entity-class attr on EntityType vendor/bin/phpunit --group type-guessing # ConventionalFieldTypeGuesser + mapper integration vendor/bin/phpunit --group integration # Real ValidatorTypeGuesser smoke tests
Requirements
- PHP 8.2+
- Symfony 6.4 / 7.0 / 8.0 —
doctrine-bridge,form,framework-bundle,validator - Doctrine ORM 3.5+,
doctrine/doctrine-bundle^3.0 - Entities must have a single-column primary key; composite primary keys are not supported
- Symfony UX Live Component ^2.13 (for
OneToMany→LiveCollectionType) - Symfony UX Autocomplete ^3.0 (for association
EntityTypeautocomplete) symfony/intl(suggested) — required at runtime if any entity uses#[Assert\Country],#[Assert\Language],#[Assert\Currency], or#[Assert\Locale]— see Type Guessing
License
MPL-2.0 — see LICENSE.
File-level copyleft: distributing a modified version of a file from this package requires that file's source to remain available under MPL-2.0. Using the package unmodified in a proprietary or closed-source application is fine. No network-use clause (unlike AGPL) — running it as part of a SaaS service triggers nothing extra. Compatible with kachnitel/admin-bundle's MIT license.