Concerns from the core thread¶
Comment #95 on #3525331 lists four questions about object-oriented render arrays. Each heading below quotes one bullet from that comment exactly. The answers describe how Form Factory Kits behaves, with pointers to the code that shows it. They do not claim to speak for the commenter or for core maintainers.
"We need to be careful with the reference use, so maybe we don't use references at all"¶
Kits do not use PHP references. A kit keeps its own state in plain arrays
($parameters, $context, and its child kits in $kits) in
Drupal\kits\Kit. FormFactoryKit::getArray() (src/Kits/FormFactoryKit.php)
starts from an empty $artifact = [] and copies each parameter into it, so
each call returns a new array that shares no references with the kit.
You can check this with:
grep -rn '&\$' src
which prints exactly two lines, both Drupal core method signatures that
FormObjectBase must match:
src/Form/FormObjectBase.php:58: public function validateForm(array &$form, FormStateInterface $form_state)
src/Form/FormObjectBase.php:67: public function submitForm(array &$form, FormStateInterface $form_state)
Caveats that a reviewer should know about:
- The Form Factory dependency (
drupal/formfactory2.1.0-beta3, commit0971922) has one reference:FormFactoryService::load()does$this->form = &$form. Because$formis a by-value parameter there, the reference points at the method's own copy, not at the caller's array, so it does not couple anything. It still should not be there in a core version. - No reference does not mean no shared state. Building a nested kit tree
changes the child kits'
parentscontext (Kit::getChildrenArray()callsappendParent()), so building the same kits twice gives a different result. See Readiness. - Objects that you put into the array yourself (for example a
TranslatableMarkuptitle) are shared by handle, as in any render array.
Relation to the AJAX failure. #3539320
describes the wrapper commit failing because, after FormBuilder copies
$form, "elements between the two stay coupled, because of use of objects and
references to arrays within the render array". A kit's output contains neither
kit objects nor references, so this particular coupling cannot come from
kits. No AJAX functional test in this repository proves that end to end.
"Without references, conversion between render arrays and objects could involve copying a lot of large arrays. Could this be a memory issue?"¶
Kits convert in one direction only, once. There is no array → object
step: a kit never reads an existing render array. There is one object → array
step: getForm() calls getArray() on each kit at the end of the build.
What that costs in memory follows from how the code works:
- During the build, the kits hold only their own parameters (a few small arrays per element) and their child kits.
- At
getForm(), the render array is built next to the kits. For a moment both exist, so peak memory is higher than building the array directly. When the kits go out of scope, only the array is left. getArray()copies parameter values into a new array. For strings and arrays PHP shares the value until one side changes it (copy-on-write), so the copy is mostly new array buckets, not duplicated strings.
Measured, three ways (Benchmarks). Bare arrays (A) are the floor; the question is how the two object approaches compare with each other. For a 500-field, 5-level form:
| A bare arrays | B wrappers (#3525331 MR, patched core) | C kits | |
|---|---|---|---|
| Build: peak | 464.2 KiB | 884.4 KiB | 1,040.2 KiB |
| Build: retained (form held) | 455.8 KiB | 883.7 KiB | 549.7 KiB |
| Alter an existing form: peak | 137.5 KiB | 312.8 KiB | not possible |
- The wrapper code in the merge request does use references, and with them
it does not copy the array when it opens it: wrapping and editing the
existing form cost less than the size of the array. The cost is one element
object per element, kept inside the array as
##object, and copies of the array stay coupled to it. - A reference-free wrapper, as the comment suggests, would have to copy elements into objects and back. No such code exists, so it is not measured.
- Kits peak about 18% higher than wrappers while
getForm()runs, and retain about 38% less afterwards, because no objects stay in the form.
"Also, if we're not using references, is there a reason to limit ourselves to the concept of "wrappers"?"¶
Kits are builders, not wrappers. A wrapper sits around an existing
array and reads and writes it in place. A kit is created empty (or from the
$parameters you pass to its factory method), collects settings through
setters, and produces an array when asked. Nothing is wrapped and nothing is
synchronised back.
- Kits are created by the
formfactorykitsservice (src/Services/FormFactoryKitsService.php), one factory method per element type. - Each kit class names one Form API
#typein a static$typeproperty and exposes only the setters that make sense for it, through traits (src/Kits/Traits/). - The output is a plain array; see Concepts.
This also means kits give up what wrappers are for: kits cannot open an existing element (for example one added by another module) and change it through an object. See the Comparison.
"Is there another approach that keeps allows us to maintain BC with render arrays without building a new render system with objects, completely in parallel, relatively from scratch?"¶
Kits sit in front of the render system, not next to it. Form API, the
form cache, AJAX, #process/#pre_render callbacks, alter hooks and the
renderer all still receive ordinary arrays, because that is all kits produce.
Nothing in core would need to accept objects.
Kits also work with existing forms, which is what backwards compatibility means in practice:
// Inside buildForm() or hook_form_alter(): $form already has elements.
$form_factory = \Drupal::service('formfactory')->load($form, $form_state);
$form_factory->append(\Drupal::service('formfactorykits')->textField('nickname'));
$form = $form_factory->getForm();
FormFactoryService::getForm() starts from the loaded $form and adds one key
per kit, so existing elements are kept. See
Altering any $form array.
What kits do not avoid: they are a second description of Form API element
properties. Each kit class restates which properties an element has
(TextFieldKit::setMaxLength() writes #maxlength, and so on), separately
from core's element plugins in Drupal\Core\Render\Element. Kits do not read
element plugin defaults or ElementInfoManager. A core version would need to
decide whether that duplication is acceptable or whether kits should be
generated from, or backed by, the element plugins.