Search by

micayael / form-generator-bundle

micayael

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

Statistics

Installs: 7 427

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

2.1.1 2026-10-17 18:51 UTC

README

CI Quality Gate Status Coverage Maintainability Rating Latest Stable Version Total Downloads PHP from Packagist License

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

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. Without position, it is default (personal_data_default).
  • The label of the group is the group value. With only position, the group is default (key default_left) and has no label.
  • Fields without extras are 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 (expression constraint)
  • inject HTML / JavaScript (label_html: true, or an onfocus attribute in attr)
  • show other data of the validated object in error messages (equal_to with propertyPath)
# 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_blank unless required: false, and date / time / datetime return strings instead of \DateTime.
  • Fields are no longer wrapped in a default_default sub-form; their data is in $form->getData().
  • createForm*() never return null.
  • 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.