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.
This package is auto-updated.
Last update: 2026-07-22 06:54:17 UTC
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, DynamicFormEditabilityListener (the PRE_SET_DATA re-check), 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.
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 - 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.