Description
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 ofForm::initialize()
Form::initialize()
can only ever be called once (usually automatically by the form system) guaranteeing the best possible performanceForm::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()
andgetViewData()
will be removed DataMapperInterface
will be deprecated in favor ofDataMapper2Interface
(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.