micayael / form-generator-bundle
Generate Symfony forms from YAML, JSON or PHP array configurations
Package info
github.com/micayael/form-generator-bundle
Type:symfony-bundle
pkg:composer/micayael/form-generator-bundle
Requires
- php: >=8.1
- ext-json: *
- symfony/config: ^5.4|^6.4|^7.4|^8.0
- symfony/dependency-injection: ^5.4|^6.4|^7.4|^8.0
- symfony/form: ^5.4|^6.4|^7.4|^8.0
- symfony/http-kernel: ^5.4|^6.4|^7.4|^8.0
- symfony/string: ^5.4|^6.4|^7.4|^8.0
- symfony/validator: ^5.4|^6.4|^7.4|^8.0
- symfony/yaml: ^5.4|^6.4|^7.4|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.1
- phpmd/phpmd: @stable
- phpstan/phpstan: ^1.4|^2.0
- phpunit/phpunit: ^9.6|^10.5|^11.0|^12.0
- symfony/framework-bundle: ^5.4|^6.4|^7.4|^8.0
- symfony/runtime: ^5.4|^6.4|^7.4|^8.0
- symfony/test-pack: ^1.1
- symfony/var-dumper: ^5.4|^6.4|^7.4|^8.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Generate Symfony forms from YAML, JSON or PHP array configurations, using the standard options of the Symfony form types. Useful for dynamic forms stored in a database or in configuration files: surveys, product attributes, custom fields per customer, etc.
- Compatibility
- Installation
- Configuration format
- Usage
- Methods and arguments
- Built-in types
- Constraints
- Groups
- Bundle configuration
- Using it without the bundle
- Error handling
- Validating configurations
- Console commands
- Security
- Upgrading from 1.x
- Development
Compatibility
| Version | PHP | Symfony |
|---|---|---|
| 2.x | >= 8.1 | 5.4, 6.4, 7.4, 8.x |
| 1.x | >= 7.4 | 5.1 – 7.x |
Installation
composer require micayael/form-generator-bundle
With Symfony Flex the bundle is registered automatically. Otherwise, add it to config/bundles.php:
return [ // ... Micayael\Bundle\FormGeneratorBundle\MicayaelFormGeneratorBundle::class => ['all' => true], ];
Configuration format
Each field of the configuration maps to a call to the form builder:
$builder->add($inputName, $inputTypeClass, $inputOptions);
| Key | Description |
|---|---|
| (the key) | the $inputName |
type |
a built-in alias or the FQCN of a form type. Default: text |
options |
the $inputOptions, merged over the defaults of the alias |
extras |
optional. group and position render the field inside a section (see Groups) |
Any other key throws an exception. A field without configuration (name: in YAML, null in JSON/PHP)
is a required text field.
The same form in the three formats:
YAML
name: birthday: type: date options: label: 'Your Birthday' status: type: choice options: choices: Active: A Inactive: I custom_type: type: App\Form\Type\CustomType options: custom_option: value
JSON
{
"name": null,
"birthday": {
"type": "date",
"options": {
"label": "Your Birthday"
}
},
"status": {
"type": "choice",
"options": {
"choices": {
"Active": "A",
"Inactive": "I"
}
}
},
"custom_type": {
"type": "App\\Form\\Type\\CustomType",
"options": {
"custom_option": "value"
}
}
}
PHP
$formConfig = [ 'name' => null, 'birthday' => [ 'type' => 'date', 'options' => ['label' => 'Your Birthday'], ], 'status' => [ 'type' => 'choice', 'options' => [ 'choices' => ['Active' => 'A', 'Inactive' => 'I'], ], ], 'custom_type' => [ 'type' => CustomType::class, 'options' => ['custom_option' => 'value'], ], ];
Usage
Inject FormGeneratorInterface (or get the public micayael.form_generator service).
In this example the form configuration is stored as YAML in a Survey entity:
use Micayael\Bundle\FormGeneratorBundle\Service\FormGeneratorInterface; class SurveyController extends AbstractController { public function __construct(private readonly FormGeneratorInterface $formGenerator) { } #[Route('/survey/{id}', name: 'survey')] public function __invoke(Survey $survey, Request $request): Response { $form = $this->formGenerator->createFormFromYaml($survey->getFormConfig()); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $answers = $form->getData(); // ['name' => 'Jhon Doe', 'birthday' => '1990-05-20', ...] // save the answers... return $this->redirectToRoute('survey_thanks'); } return $this->render('survey/show.html.twig', [ 'form' => $form, ]); } }
{# templates/survey/show.html.twig #} {{ form_start(form) }} {{ form_widget(form) }} <button type="submit">Send</button> {{ form_end(form) }}
The generated form doesn't include a submit button, so add it in the template (or with
createFormBuilder()).
Methods and arguments
| Method | Returns |
|---|---|
createForm(array $formConfig, ...) |
FormInterface |
createFormFromJson(string $formConfigJson, ...) |
FormInterface |
createFormFromYaml(string $formConfigYaml, ...) |
FormInterface |
createFormBuilder(array $formConfig, ...) |
FormBuilderInterface |
validateConfig(array|string $formConfig, ...) |
errors by field (see Validating configurations) |
All of them accept the same optional arguments. Named arguments are recommended:
| Argument | Description |
|---|---|
$formOptions |
options of the root form |
$data |
initial data of the form (array or object) |
$baseFormTypeClass |
form type (FQCN) used as the base to add the fields. Default: FormType |
$groupName |
if set, the fields are added inside an embedded form with this name |
$formName |
name of the root form. Default: the block prefix of the base form type (form) |
An empty configuration returns an empty form (the base form type without extra fields).
createForm
From a PHP array:
$form = $this->formGenerator->createForm([ 'name' => null, 'email' => ['type' => 'email'], ]);
createFormFromJson
From a JSON string, e.g. a JSON column of the database or the body of an API request:
$form = $this->formGenerator->createFormFromJson($product->getAttributesFormJson());
createFormFromYaml
From a YAML string, e.g. a file of your project:
$form = $this->formGenerator->createFormFromYaml( file_get_contents($this->getParameter('kernel.project_dir').'/config/forms/contact.yaml') );
createFormBuilder
Returns the builder, to keep adding fields, listeners or a submit button before creating the form:
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; $builder = $this->formGenerator->createFormBuilder($formConfig); $builder ->add('accept_terms', CheckboxType::class, ['mapped' => false]) ->add('save', SubmitType::class) ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { // ... }) ; $form = $builder->getForm();
$formOptions and $data
$formOptions configures the root form, and $data fills it with initial values:
$form = $this->formGenerator->createForm( $formConfig, formOptions: ['attr' => ['novalidate' => 'novalidate'], 'validation_groups' => ['Default']], data: ['name' => 'Jhon Doe', 'birthday' => '1990-05-20'], );
$baseFormTypeClass and $groupName
Add dynamic fields to an existing form type. With $groupName they are grouped in an embedded form,
so they don't mix with the fixed fields:
// ProductType has the fixed fields: name, price $form = $this->formGenerator->createForm( $category->getAttributesFormConfig(), // e.g. ['color' => null, 'size' => ['type' => 'choice', ...]] data: $productData, baseFormTypeClass: ProductType::class, groupName: 'attributes', ); $form->getData(); // [ // 'name' => 'T-shirt', // 'price' => 10, // 'attributes' => ['color' => 'red', 'size' => 'M'], // ]
If the base form type uses a data_class, the object needs an attributes property (e.g. an array
or JSON column).
$formName
Sets the name of the root form, which is the prefix of the HTML field names (contact[email]).
Useful to render several generated forms in the same page:
$contactForm = $this->formGenerator->createForm($contactConfig, formName: 'contact'); $surveyForm = $this->formGenerator->createForm($surveyConfig, formName: 'survey');
Built-in types
| Alias | Form type | Default constraints / options |
|---|---|---|
text |
TextType | not_blank |
textarea |
TextareaType | not_blank |
email |
EmailType | not_blank, email |
password |
PasswordType | not_blank |
url |
UrlType | not_blank, url |
tel |
TelType | not_blank |
uuid |
UuidType | not_blank |
integer |
IntegerType | not_blank, type: integer |
number |
NumberType | not_blank, type: numeric |
money |
MoneyType | not_blank, type: numeric |
percent |
PercentType | not_blank, type: numeric |
choice |
ChoiceType | not_blank |
country |
CountryType | not_blank |
language |
LanguageType | not_blank |
locale |
LocaleType | not_blank |
timezone |
TimezoneType | not_blank |
currency |
CurrencyType | not_blank |
date |
DateType | not_blank, date; widget: single_text, input: string |
time |
TimeType | not_blank, time; widget: single_text, input: string, with_seconds: false |
datetime |
DateTimeType | not_blank, datetime; widget: single_text, input: string, with_seconds: false |
boolean |
CheckboxType | type: boolean; required: false |
boolean_choice |
ChoiceType | type: boolean; required: false, choices Yes/No |
Your options are merged over the defaults:
nickname: type: text options: required: false # also removes the default not_blank constraint label: Nickname
When a FQCN is used as type, no defaults are applied: the form type is used as it is.
The date types work with strings (Y-m-d, H:i:s, Y-m-d H:i:s) by default. To get date objects,
change the input and disable the default constraint, since it validates a string:
birthday: type: date options: input: datetime_immutable constraints: date: false
To add your own aliases or change the defaults, see Bundle configuration.
Constraints
The constraints option accepts any Symfony constraint
by its snake_case name (not_blank, length, regex, greater_than, ...) or its FQCN, with its options
as a map:
code: options: constraints: length: { min: 3, max: 10 } regex: { pattern: '/^[A-Z]+$/' } age: type: integer options: constraints: range: { min: 18, max: 99 }
Constraints without options can be listed by name:
email: type: text options: constraints: [not_blank, email]
name: false disables a default constraint of the alias:
email: type: email options: constraints: not_blank: false # optional, but must be a valid email if filled
In PHP you can also use Constraint objects:
use Symfony\Component\Validator\Constraints\Length; $form = $this->formGenerator->createForm([ 'code' => ['options' => ['constraints' => ['not_blank', new Length(max: 10)]]], ]);
A constraint defined twice (by name, alias or FQCN) is created once, with the last options.
required: false removes the not_blank constraint, whichever way it was defined.
Nested constraints
Constraints that contain other constraints (all, at_least_one_of, sequentially, when,
collection, required, optional, ...) are resolved too:
tags: type: Symfony\Component\Form\Extension\Core\Type\CollectionType options: allow_add: true constraints: all: constraints: length: { max: 20 } contact: type: App\Form\Type\ContactType options: constraints: collection: fields: email: [not_blank, email] phone: optional: constraints: length: { max: 15 }
Groups
Fields with extras.group and/or extras.position are added inside a sub-form, to render them as
sections in the template. Their data stays in the parent form (they use inherit_data), so
$form->getData() doesn't change.
first_name: extras: group: Personal data position: left last_name: extras: group: Personal data position: left phone: extras: group: Contact position: right notes: type: textarea
- The key of the group is the slug of
group_position:personal_data_left,contact_right. Withoutposition, it isdefault(personal_data_default). - The label of the group is the
groupvalue. With onlyposition, the group isdefault(keydefault_left) and has no label. - Fields without
extrasare added directly to the form (notes). - A field can't have the same name as a group: it throws an exception.
$form['personal_data_left']['first_name']; // the field $form->getData(); // ['first_name' => ..., 'last_name' => ..., 'phone' => ..., 'notes' => ...]
Rendering the sections in two columns:
{{ form_start(form) }}
<div class="row">
<div class="col">
{{ form_row(form.personal_data_left) }}
</div>
<div class="col">
{{ form_row(form.contact_right) }}
</div>
</div>
{{ form_rest(form) }}
<button type="submit">Send</button>
{{ form_end(form) }}
Bundle configuration
All the configuration is optional:
# config/packages/micayael_form_generator.yaml micayael_form_generator: # custom aliases, or overrides of the built-in ones types: # override: only the default_options you set are replaced boolean_choice: default_options: choices: { 'Yes': true, 'No': false } # new alias, usable as "type: product_category" product_category: class: App\Form\Type\ProductCategoryType default_options: required: false constraints: not_blank: false # restrict the FQCN usable in "type" (the aliases are always allowed). Empty: any form type is allowed allowed_classes: - App\Form\Type\ProductCategoryType - Symfony\Component\Form\Extension\Core\Type\CollectionType # when the configurations come from users, see "Security" untrusted: false allowed_options: [] allowed_constraints: [] allowed_html_attributes: []
Using it without the bundle
FormGenerator only needs a form factory, so it also works without the bundle (in tests, scripts
or apps using the standalone Form component). The other arguments are the same types and
allowed_classes of the bundle configuration:
use Micayael\Bundle\FormGeneratorBundle\Service\FormGenerator; use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Form\Forms; use Symfony\Component\Validator\Validation; $formFactory = Forms::createFormFactoryBuilder() ->addExtension(new ValidatorExtension(Validation::createValidator())) ->getFormFactory(); $formGenerator = new FormGenerator( $formFactory, types: ['boolean_choice' => ['default_options' => ['choices' => ['Yes' => true, 'No' => false]]]], allowedClasses: [], ); $form = $formGenerator->createFormFromYaml($yaml);
Error handling
Invalid configurations throw an exception implementing
Micayael\Bundle\FormGeneratorBundle\Exception\ExceptionInterface:
| Exception | When |
|---|---|
InvalidConfigurationException |
invalid JSON/YAML, unknown keys in a field, unknown constraints or options, callables not given as \Closure, field and group with the same name, options or constraints not allowed in untrusted mode |
NotSupportedFormTypeException |
type is not an alias nor a form type class, or it is not in allowed_classes |
Both extend \InvalidArgumentException. The messages explain the problem, e.g.
Unknown key(s) "typ" in the configuration of the field "name". Allowed keys: "type", "options", "extras".
use Micayael\Bundle\FormGeneratorBundle\Exception\ExceptionInterface; try { $form = $this->formGenerator->createFormFromJson($survey->getFormConfig()); } catch (ExceptionInterface $e) { $this->logger->error('Invalid survey form', ['survey' => $survey->getId(), 'error' => $e->getMessage()]); throw $this->createNotFoundException(); }
Validating configurations
validateConfig() checks a configuration without throwing, e.g. before saving it in an admin panel or in
an API. It returns the error messages by field name ("" for errors of the whole configuration, like invalid
JSON/YAML), or an empty array if it is valid. It applies the same rules as the createForm*() methods
(aliases, constraints, Symfony form options and the untrusted mode), so pass the same arguments you will use
to create the form:
// $format: "yaml" or "json" when the configuration is a string (ignored for arrays) $errors = $this->formGenerator->validateConfig($request->getContent(), 'json', baseFormTypeClass: ProductType::class, groupName: 'attributes'); if ($errors) { return $this->json(['errors' => $errors], 422); } // ['age' => 'Form type "integr" is not supported. ...', 'email' => 'The option "foo" does not exist. ...']
Console commands
When symfony/console is installed, the bundle registers two commands.
micayael:form-generator:validate validates .yaml, .yml and .json files (or directories), for example in CI:
$ php bin/console micayael:form-generator:validate config/forms
[OK] config/forms/contact.yaml
[ERROR] config/forms/survey.json
- age: Form type "integr" is not supported. ...
[ERROR] 1 of 2 file(s) are not valid.
Use --base-type and --group to validate them like the $baseFormTypeClass and $groupName arguments.
The command returns 1 if any file is invalid.
micayael:form-generator:debug lists the type aliases (built-in and custom) with their form type and
default options, and the security configuration (untrusted mode and allowed classes, options, constraints
and HTML attributes).
Security
The bundle has two modes, depending on who writes the form configurations.
Trusted configurations (default)
When the configurations are written by developers (files of the project, fixtures, ...), every
form type, option and constraint can be used. Even so, to avoid running arbitrary code from YAML/JSON,
callables are only accepted as \Closure (that is, from PHP code):
- constraint options typed as callables (
normalizer,callback, ...) - form options that Symfony accepts as callables (
is_empty_callback,getter,setter,delete_empty,validation_groups, ...). Options where Symfony uses a string as a property path (choice_label,choice_value,group_by, ...) still accept strings, but not array callables
# throws an exception: "normalizer" only accepts a \Closure name: options: constraints: not_blank: { normalizer: trim }
Untrusted configurations
When the configurations can be written by users (an admin panel, a form builder for customers, an API, ...), enable the untrusted mode. Without it, a malicious configuration could, for example:
- list the data of any Doctrine entity in the HTML (
type: EntityType,class: App\Entity\User,choice_label: password) - change properties of your objects that are not in the form (
property_path: roles) - call methods of your objects (
expressionconstraint) - inject HTML / JavaScript (
label_html: true, or anonfocusattribute inattr) - show other data of the validated object in error messages (
equal_towithpropertyPath)
# config/packages/micayael_form_generator.yaml micayael_form_generator: untrusted: true # FQCN form types allowed in "type". In untrusted mode, empty means only the aliases allowed_classes: - Symfony\Component\Form\Extension\Core\Type\CollectionType # blocked options allowed anyway allowed_options: [label_html] # constraints allowed anyway (name or FQCN), e.g. your own constraints allowed_constraints: - App\Validator\ValidTaxId # HTML attributes allowed anyway, besides the safe ones. Values ending with "-" are prefixes allowed_html_attributes: ['data-mask']
In untrusted mode:
| Rule | Detail |
|---|---|
| Form types | only the aliases (built-in and the ones in types) and the classes in allowed_classes |
| Blocked options | property_path, mapped, inherit_data, by_reference, data, data_class, empty_data, getter, setter, entry_type, entry_options, prototype_options, label_html, help_html, block_name, block_prefix |
| Constraints | only the Symfony ones, except expression, when and callback, plus allowed_constraints (also inside nested constraints). The propertyPath, minPropertyPath and maxPropertyPath options are blocked |
| HTML attributes | in attr, row_attr, label_attr, help_attr, placeholder_attr and choice_attr, only class, placeholder, title, rows, cols, size, maxlength, minlength, min, max, step, pattern, autocomplete, autofocus, inputmode, spellcheck, readonly, dir, lang, aria-* and allowed_html_attributes, with scalar values. Twig escapes the values, not the names, so event handlers (onclick, ...) or style must not be allowed |
| Objects | the fields can't be added to a form with a data_class, because their names would write properties of the object: use $groupName to store them in an array property |
| Base form type | the fields can't replace the fields of $baseFormTypeClass |
// ProductType has a data_class: the dynamic fields go to the "attributes" array property $form = $this->formGenerator->createFormFromJson($userConfig, data: $product, baseFormTypeClass: ProductType::class, groupName: 'attributes');
Without the bundle, pass untrusted: true (and allowedOptions, allowedConstraints, allowedHtmlAttributes)
to the FormGenerator constructor.
Be careful with allowed_html_attributes: ['data-']: data-controller and data-action connect
Stimulus controllers of your app.
The configuration is still user input: the texts (labels, help, placeholders) are escaped by Twig, but review your templates if you render them in other ways, and limit the size of the configurations you accept.
Schema for visual editors
SchemaProvider describes what a FormGenerator accepts, as an array that can be encoded to JSON: the form
types (aliases from types plus allowed_classes) with their options, and the allowed constraints with
their parameters.
use Micayael\Bundle\FormGeneratorBundle\Service\SchemaProvider; $schema = $schemaProvider->getSchema($formGenerator); // ['untrusted' => bool, 'types' => [alias => ['class' => ..., 'options' => [name => ['types' => [...], 'default' => ...]]]], 'constraints' => [...]]
Options blocked in untrusted mode (except allowed_options), options that only accept callables and
constraints are left out; so are the groups, payload, options and callable parameters of the constraints.
Upgrading from 1.x
Version 2.0 requires PHP 8.1 and fixes bugs that changed the behavior, so review your forms:
- The defaults of the aliases are applied now (in 1.x they never were): fields get
not_blankunlessrequired: false, anddate/time/datetimereturn strings instead of\DateTime. - Fields are no longer wrapped in a
default_defaultsub-form; their data is in$form->getData(). createForm*()never returnnull.- Unknown keys, constraints or options throw an exception instead of being ignored.
- Callables in the configuration must be
\Closure.
See the CHANGELOG for the full list.
Development
composer install
composer test
composer phpstan
composer cs
vendor/bin/phpmd src text .phpmd-ruleset.xml
License
MIT. See LICENSE.