Skip to content

[Form] Harden the form lifecycle to improve support for dynamic forms #8834

Closed
@webmozart

Description

@webmozart

Probably in Symfony 2.4 I'm going to make a few changes to the form architecture to enforce a proper form lifecycle. A BC layer will be provided until Symfony 3.0.

I'm writing this mainly as instructions to my future me, but anyone interested in the topic is invited to chime in :)

Summary

  • Form::setData() will be deprecated in favor of Form::initialize()
  • Form::initialize() can only ever be called once (usually automatically by the form system) guaranteeing the best possible performance
  • Form::initialize() is guaranteed to be called on every element in the form tree (including those with "inherit_data" set), ensuring that all *_SET_DATA events are fired on time
  • side effects from Form::submit(), getData(), getNormData() and getViewData() will be removed
  • DataMapperInterface will be deprecated in favor of DataMapper2Interface (everybody cheer now ;) )

Question

After you read the text ( ;) ) please help me by answering the following question:

Are there use cases where calling Form::setData() is absolutely necessary and cannot be changed so that the default data is set in one of the other ways demonstrated below (last code sample)?

Motivation

Motivation 1: Dynamic Forms

The motivation behind are dynamic forms (#5807). Dynamic forms rely on setData() being called in order to manipulate the form tree dynamically. This must be done before get*Data(), submit() or createView() are called, because these methods rely on the form tree being readily initialized.

Motivation 2: *_SET_DATA events and "inherit_data"

Currently, forms with the "inherit_data" option set cannot have *_SET_DATA event listeners, because setData() is never called on these forms (#8253, #8607, #8748).

Motivation 3: Performance

Depending on the number of nested forms and the number of event listeners, setData() can be a very expensive operation. The number of setData() calls were already reduced once in d4f4038 by introducing deferred data initialization. It is still possible to call setData() more than once though. Guaranteeing a single setData() call would also guarantee an optimal performance.

Motivation 4: Remove side effects

The result of deferred data initialization is that getData(), getNormData(), getViewData() and submit() now have side effects. I would like to remove those.

Motivation 5: Inline data mapping code

Currently, when writing a custom data mapper, most of the code from PropertyPathMapper has to be duplicated. In fact, the only implementation-dependent lines are

$form->setData($this->propertyAccessor->getValue($data, $propertyPath));

and

$this->propertyAccessor->setValue($data, $propertyPath, $form->getData());

Even worse, the other code in the data mappers is responsible for maintaining the correct lifecycle of the form. I would like to inline this critical code into Form and provide a new, more minimal data mapper interface.

Proposed Lifecycle

The proposed form lifecycle will consist of four phases, which partially overlap.

Note in advance: I'm talking about the existing low-level API here. Don't confuse it with the high-level API that you typically use in your controllers (types, options, etc.)

Phase 1: Configuration

In this phase, form builders are created and configured.

The phase is complete when a form builder is turned into a form by calling getForm(). At this point, the default data and other configuration of the form are frozen.

$builder = $factory->createBuilder()
    ->setData('default')
    ->setDisabled(true)
    ->setCompound(false)
    // ...
Phase 2: Assembling

In this phase, the form tree is assembled by connecting the Form instances. Typically, this is done during FormBuilder::getForm(), but in dynamic forms you often need to call Form::add() and Form::remove() manually.

This phase is never really complete. The form tree can be changed in the later phases to allow a dynamic modification of the form tree.

// Configuration
$builder = $factory->createBuilder()
    ->add('name', 'text')
    ->add('email', 'email');

// Assembling
// Form instances are created, Form::add() is called
$form = $builder->getForm();

// Manual modifications
$form->add('submit', 'submit');
$form->remove('email');
Phase 3: Initialization

During initialization, the form tree is populated with default values. Initialization starts when Form::initialize($defaultData = null) is called on the root form. The call will cascade through the form tree.

After the call is complete, initialize() is guaranteed to have been called on every element in the form tree. If elements are added later on, initialize() will be called on them during Form::add().

initialize() is very similar to the current setData(), i.e. the form's data is set and *_SET_DATA events are fired. Consequently, after the initialization of a form tree, all *_SET_DATA events are fired and their effects visible.

There are three major differences between initialize() and the current setData():

First, initialize() must only be called once. Since initialize() cascades through the form tree, this implies that initialize() must only be called on the root form.

Second, the data argument in can be omitted. This happens mostly for the root form and when "mapped" is set to false. In this case, the default data will be retrieved from the form's configuration, which has been created in Phase 1.

$form = $factory->createBuilder('form', null, array('auto_initialize' => false))
    ->add('name', 'text')
    ->add('email', 'email')
    // passing the data as second argument to
    // FormFactory::createBuilder() or through the option
    // "data" is equivalent to the below statement
    ->setData(array('name' => 'foo', 'email' => 'bar'))
    ->getForm();

$form->initialize();
// sets the default data stored above

// implies $form->get('name')->initialize('foo')
// implies $form->get('email')->initialize('bar')

If the "auto_initialize" option is not set to false, as in this example, initialize() will be called automatically (this is already happening right now for your Symfony 2.3 forms).

Third and last, contrary to the current setData(), initialized forms cannot be added as children of other forms anymore.

$form = $factory->createForm('text', null, array('auto_initialize' => false));
$form->initialize('foo');

$otherForm->add($form); // boom

$form = $factory->createForm('text');

$otherForm->add($form); // boom, auto initialized
Phase 4: Submission

After a form tree is readily initialized and all *_SET_DATA events triggered, the form can be submitted. Forms must not be submitted before initialization is complete.

// Phase 1 and 2 inside the form type
// Phase 3 due to automatic initialization
$form = $this->createForm('my_type');

// Phase 4
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    // ...
}

The only difference will be that there will most likely be an "auto_submit" option with a default of true, sparing you of calling handleRequest() manually.

// Phase 1 and 2 inside the form type
// Phase 3 due to automatic initialization
// Phase 4 due to automatic submission
$form = $this->createForm('my_type');

if ($form->isSubmitted() && $form->isValid()) {
    // ...
}

Implementation

A few details on what changes are necessary to make the above happen.

Introduce new DataMapperInterface

A new data mapper interface will be introduced. Due to BC, it will have a very uninspring name:

interface DataMapper2Interface
{
    /**
     * Returns true if the data is considered empty. The
     * preconfigured default value will be used in this case.
     *
     * @param mixed $data
     *
     * @return Boolean
     */
    public function isEmpty($data);

    /**
     * Returns whether the data can be read. For example,
     * an ArrayAccessMapper will return true if and only if
     * $data is an array.
     *
     * @param mixed $data
     *
     * @return Boolean
     */
    public function isReadable($data);

    /**
     * Returns a textual representation of the types that
     * this mapper can read, which will be included in the
     * error message generated if isReadable() returns false.
     *
     * @return string
     */
    public function getReadableTypes();

    /**
     * Returns the mapped value from $data by accessing $path.
     * $path can be anything, depending on the implementation
     * (e.g. property path, XPath, array index etc.)
     *
     * @param mixed $data
     * @param mixed $path
     *
     * @return mixed The value read from $data
     */
    public function readValue($data, $path);

    /**
     * Writes a value into $data.
     *
     * @param mixed $data
     * @param mixed $path
     * @param mixed $value
     */
    public function writeValue($data, $path, $value)
}

This mapper can be used alternatively to an implementation of the current DataMapperInterface. The other code contained in current DataMapperInterface implementation will be executed by Form directly (see Motivation 5).

Call initialize() on forms with "inherit_data" true

Once the data mapping code is inlined into Form, initialize() can also be called on forms with the "inherit_data" option set (see Motivation 2). This is currently impossible, because the current data mappers only have access to the parent's view data, but need to pass the parent's model data instead.

Remove side effects

The side effects from getData(), getNormData(),
getViewData() and submit() should be removed. Instead, exceptions should be thrown.

// old
if (!$this->defaultDataSet) {
    $this->setData($this->getConfig()->getData());
}

// new
if (!$this->defaultDataSet) {
    throw new RuntimeException('getData() must be called after initializing the form. Make sure "auto_initialize" is not disabled on the root form.');
}

This is possible, because initialize(), which is guaranteed to be called, also guarantees to fill the form with the appropriate default data.

Deprecate setData()

As last step, setData() will be deprecated. Consequently, you should only set default data during the configuration phase by one of the already existing means:

// passed to the factory
$form = $factory->create('form', $default);

// set on the builder
$form = $factory->createBuilder('form')
    ->setData($default)
    ->getForm();

// passed as option
$form = $factory->create('form', null, array('data' => $default));
$form->add('field', 'text', array('data' => $default));

// deprecated
$form->setData($default);

If you made it up to here, you have my full respect :) Please don' forget to answer my question above.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions