Three options compared¶
This page compares three ways of building Drupal forms:
- A: bare render arrays, the status quo. It is the floor: every object layer adds cost on top of it, so A is the baseline the other two are measured from, not a competitor.
- B: element-plugin object wrappers, as proposed in #3525331.
- C: Form Factory Kits, this module.
The useful comparison is B against C: both offer objects instead of arrays, and they make different trade-offs.
Sources. B is described from its merge request code (!12173, committed to 11.x in June 2025 and reverted in August 2025, and the rebased !13985), which was applied to a copy of Drupal 11.4.8 and run for the benchmarks. It is not in any Drupal release, and the issue may change the design. C is described from this repository, with links to the page or file that shows each point.
The same element, three ways¶
A:
$form['test_rebuild'] = [
'#type' => 'submit',
'#value' => $this->t('Rebuild'),
'#submit' => [[Callbacks::class, 'userRegisterFormRebuild']],
];
B (the issue summary's example; in the merge request code the first method
is called fromRenderable(), the summary calls it fromRenderArray()):
$submit = $this->elementInfoManager->fromRenderable($form)
->createChild('test_rebuild', Submit::class);
$submit->value = $this->t('Rebuild');
$submit->submit = [[Callbacks::class, 'userRegisterFormRebuild']];
C:
$form_factory = $this->formFactory->load($form);
$form_factory->append(
$this->kits->submit('test_rebuild')
->setValue($this->t('Rebuild'))
->set('submit', [[Callbacks::class, 'userRegisterFormRebuild']])
);
$form = $form_factory->getForm();
Kits have no setSubmit(), so the generic set() from Drupal\kits\Kit is
used for #submit.
Side by side¶
| Aspect | A: bare arrays | B: element wrappers (#3525331) | C: Form Factory Kits |
|---|---|---|---|
| Construction | Array literals. | ElementInfoManager::fromRenderable($form) or fromClass(), then createChild($name, Element::class) / addChild(). |
Factory methods on the formfactorykits service, one per element type (kit reference); children with append(); FormFactory::getForm() assembles the array (Concepts). |
| Property access | $el['#title']. |
Magic __get / __set: $el->title maps to #title. Any name is accepted. |
Typed setters from traits (setTitle(), setRequired(), ...) (trait reference); anything else through set('name', $value). |
| Open an existing array in place | Yes (it is the array). | Yes. fromRenderable($form) wraps the existing array by reference, so code can edit elements another module added. |
No. Kits only build new elements. load($form) keeps the existing array and adds kits next to it; existing elements stay array code (Concepts). |
| References | None unless the code adds them. | Yes, by design: initializeInternalStorage() does $this->storage = &$element, toRenderable() returns by reference, addChild() stores &$child->toRenderable(). |
None in kit output. grep -rn '&\$' src finds only two core method signatures (Concerns). |
| Objects inside the array | None. | One ##object entry per element, pointing back at the object that holds the array: 506 for the 500-field form (Benchmarks). |
None; kit objects are discarded after getForm() (Benchmarks). |
Copying $form |
Independent copy. | Measured: the copy stays coupled to the original (Benchmarks). | Measured: independent copy (Benchmarks). |
| Recursion and serialization | Plain data. | Circular (##object → storage → array containing ##object); var_export() warns about circular references. serialize() works through __sleep() / __wakeup(), gives 2.8× the bytes of A, and removes ##object from the live array as a side effect (Benchmarks). |
Plain data; serialized size identical to A (Benchmarks). |
| AJAX and form cache | The current, working behavior. | Reverted after #3539320, which states that after $form is copied, "elements between the two stay coupled, because of use of objects and references to arrays within the render array". |
The array contains no kit objects or references, so that coupling cannot come from kits. Not proven by an AJAX test in this repository (Readiness). |
| BC with render arrays | Is the render array. | Wrappers are the core element plugins; the array they hold goes through the existing pipeline, with ##object entries added. |
Output is a plain render array, so nothing downstream changes (Altering any $form array). |
| Memory and time (build, 500 fields) | 464 KiB peak, 456 KiB retained, ~0.3 ms. | 884 KiB peak, 884 KiB retained, ~3 ms. | 1,040 KiB peak, 550 KiB retained, ~1.0–2.0 ms (Benchmarks). |
| Round trip (alter existing form) | ~138 KiB for the edit. | ~313 KiB peak (the array is not copied; the cost is the element objects). | Not possible (Benchmarks). |
| Nesting | Nested arrays. | addChild(), createChild(), getChild(), getChildren(), removeChild(). |
append(); vertical tabs group their children (Nesting). No remove or get-child. Nested children get #parents set to ancestors only, which needs review (Readiness). |
| Extensibility | Any #type. |
Every element plugin, core or contrib, is a wrapper automatically. | One kit class per element type (52 *Kit.php classes in src/Kits/, including abstract bases) (kit reference); new ones extend FormFactoryKit (Writing your own kit). |
| Source of truth for properties | Element plugin docs. | The element plugin itself. | The kit class, separate from the element plugin (Concerns). |
| Dependency and merge cost | None. | Core changes only: ElementInfoManager, RenderElementBase, ElementInterface, every element class, FormBase, some widgets (74 files in !13985, tests included). |
A new module (76 PHP files in src/) plus the contributed kits, formfactory and query modules it depends on, all beta (Merge path). |
| Current status | Shipping. | Reverted; issue "Needs work"; rebased MR open. | Contrib, 2.1.0-beta1; several open readiness items (Readiness). |
What B does that C does not¶
- Open an existing array in place. Code can take a form built by someone else and edit its elements through objects. With kits, editing existing elements is still array code.
- Cover every element type with no extra classes. A wrapper is the element plugin, so contrib element types work at once. Kits need a class per type, kept in step with the element's properties.
- Accept any property directly.
$element->anything = ...works; kits use the less discoverableset('anything', ...). - Lower peak memory while building. B builds straight into the array; kits briefly hold the kit objects and the finished array together (about 18% higher peak than B in the benchmark).
- Need nothing outside core. Kits currently depend on three beta contributed modules.
What C does that B does not¶
- Return a plain array. No objects and no references stay in the form, so copies are independent, serialization is unchanged, and the class of coupling described in #3539320 cannot come from kits.
- Free its objects after building. The retained form is about 38% smaller than B's and about 21% larger than A's.
- Build faster on the 500-field form. About 0.3–0.4× B's median in the 28 October 2026 repeats (still several times A's). On 5,000 fields that run was within noise.
- Typed, discoverable setters. IDEs can list what a
TextFieldKitsupports, and PHP checks argument types. - Work today without core changes. The module declares support for Drupal 10.2 and 11, with the known bugs listed under Readiness.