Skip to content

Benchmarks

This page measures three ways of producing the same Drupal form:

  • A: bare render arrays (the status quo). This is the floor. Any object layer costs more than writing arrays by hand, so A is the reference point for the other two, not something they compete with.
  • B: element-plugin object wrappers, the code proposed in #3525331, measured for real on a patched Drupal core.
  • C: Form Factory Kits, this module.

The comparison that matters is B against C. Comment #95 on the core issue asks whether converting between render arrays and objects "could be a memory issue", so the page also measures the round trip that only B can do: opening an existing array as objects, editing it, and handing the array back.

How B was obtained

Fetched 28 October 2026: #3525331 is Needs work (field_issue_status 13), comment_count 95, latest comment still #95. #3539320 is also Needs work, 33 comments. The wrapper code is not in any Drupal release.

  • Source: GitLab !13985 HEAD 91930afa94d95026a353f41f49227d0e1ae19cae (opened, target main, detailed_merge_status: conflict). fromRenderable(), fromClass(), toRenderable(), createChild(), getChildren(), and Drupal\Core\Render\Element\ElementInterface are present on that SHA.
  • Applied: git apply --reject of that diff onto Drupal 11.4.8 (drupal/recommended-project). The wrapper API files applied cleanly. Rejects: WidgetBase.php, ModerationStateWidget.php, BreakLockLink.php, LayoutBuilder.php, HtmlTagTest.php. None of those is loaded by the harness. B apply: ok.
  • Booted: only the real ElementInfoManager plus a container holding it. Module and theme handlers are do-nothing objects. No database, no kernel.

Method

Script: docs/benchmarks/memory.php (kept out of the built site). From the module root:

# A and C only (no patched core needed):
php docs/benchmarks/memory.php
# A, B and C:
FFK_WRAPPER_CORE=/path/to/patched/core FFK_DRUPAL_VENDOR=/path/to/drupal/vendor \
FFK_KITS_DIR=/path/to/kits FFK_FORMFACTORY_DIR=/path/to/formfactory \
  php docs/benchmarks/memory.php [--fields=5000]
  • Fixtures. nested-textfields (default): 5 nested details and 500 or 5,000 textfields. mixed-small: one details with textfield, textarea, select, radios, checkboxes, number, and a submit (types C ships without a kernel). mixed-nested: that mixed form inside one extra details.
  • Same data. All three variants set the same properties, including the #parents and #group keys kits add. The script normalizes each result (removing B's ##object) and checks the hashes.
  • Scenario "build". Create the whole form from nothing.
    • A implements the tree as a FormInterface-style array.
    • B uses fromRenderable() / createChild() / magic properties.
    • C is a FormObjectInterface implementor whose buildFormObject(FormFactoryInterface, FormFactoryKitsInterface) calls $kits->details(), $kits->textField(), and the other factories, then FormFactoryInterface::getForm().
  • JSON. --out= writes the numbers used on this page under docs/benchmarks/results/. --published refuses to exit 0 if C used stand-ins or B was skipped.
  • Scenario "alter". Start from an existing form (built as A before the measurement starts and counted in the baseline) and change the description of every text field.
    • A walks the array with a recursive function.
    • B opens it with fromRenderable(), walks it with getChildren(), and edits through magic properties. This is the array → object → array round trip from comment #95.
    • C is not measured: kits cannot open an existing array.
  • Memory. Each variant runs in its own PHP process. After a small warm-up run (classes and plugin definitions loaded), the script records memory_get_usage() as the baseline and resets the peak. Peak is the highest memory above the baseline during the run. Retained is what is still allocated afterwards, with the form held and everything else freed (gc_collect_cycles() is called first).
  • Objects. The number of ##object entries left inside the final array.
  • Copy coupled. After "build", the script copies the form ($copy = $form), changes one field's title in the original, and checks whether $copy changed too.
  • Serialized. strlen(serialize($form)), whether unserialize() gives back the same element data, and how many ##object entries the original array has after serialize() ran.
  • Time. Median, p95, minimum and maximum of 20 runs, hrtime().
  • Kits dependencies. Published rows use real drupal/kits and drupal/formfactory. Stand-ins remain for a local A/C smoke only.

Environment

  • PHP 8.3.30 CLI, Linux (WSL2) x86_64, Intel Core i7-1360P, OPcache CLI off. Extra row: PHP 8.5.12 in Docker image ffk-php85.
  • B: Drupal 11.4.8 core with MR !13985 at 91930afa, applied as above.
  • C: drupal/kits 2.0.0-beta5, drupal/formfactory 2.1.0-beta3 (git tags), this module HEAD, through FormObjectInterface + FormFactoryKitsInterface.
  • Run on 28 October 2026. Machine JSON: docs/benchmarks/results/2026-10-28-php83-nested-500-r1.json (and r2, r3), …-nested-5000.json, …-mixed-small.json, …-php85-nested-500.json.

Results

Memory figures were identical on every run (three isolated-process repeats of the 500-field form, and the PHP 8.5.12 extra row). Times varied with load. Over the three PHP 8.3 repeats, the B build median was 2.87–4.54 ms and the C build median 1.03–1.96 ms. Full output of repeat 1:

500 fields (28 October 2026)

$ php docs/benchmarks/memory.php --published --out=docs/benchmarks/results/2026-10-28-php83-nested-500-r1.json
C API: FormObjectInterface+FormFactoryKitsInterface
Published three-way: yes
Form: fixture=nested-textfields, 500 textfields in 5 nested details levels, 20 timed iterations

[build] build the form from nothing
Same element data in all variants (ignoring '##object'): yes
variant              peak      retained  median ms     p95 ms     min ms     max ms  objects
A raw           464.2 KiB     455.8 KiB       0.34       0.47       0.33       0.50        0
B wrappers      884.4 KiB     883.7 KiB       3.35       8.44       2.05      16.74      506
C kits        1,040.2 KiB     549.7 KiB       1.03       2.58       0.72       3.67        0
variant      copy coupled    serialized   unserialize ok objects after ser.
A raw                  no     147.4 KiB              yes                  0
B wrappers            yes     414.8 KiB              yes                  0
C kits                 no     147.4 KiB              yes                  0
C kits / B wrappers: peak x1.18, retained x0.62, median time x0.31

[alter] alter descriptions of an existing form (kits cannot do this)
Same element data in all variants (ignoring '##object'): yes
variant              peak      retained  median ms     p95 ms     min ms     max ms  objects
A raw           137.5 KiB     137.5 KiB       0.26       0.37       0.18       0.46        0
B wrappers      312.8 KiB     294.7 KiB       1.30       3.09       0.93       3.19      506

5,000 fields (28 October 2026)

$ php docs/benchmarks/memory.php --fields=5000 --published --out=docs/benchmarks/results/2026-10-28-php83-nested-5000.json
[build] Same element data: yes
variant              peak      retained  median ms     p95 ms  objects
A raw         4,382.7 KiB   4,342.3 KiB       1.86       2.30        0
B wrappers    8,651.0 KiB   8,650.3 KiB      26.29      55.19     5006
C kits       10,111.9 KiB   5,336.0 KiB      28.67      33.27        0
C kits / B wrappers: peak x1.17, retained x0.62, median time x1.09
serialize: A and C 1,487.8 KiB; B 4,148.8 KiB; B copy coupled yes
[alter] A 1,371.4 / 1,367.9 KiB 5.07 ms; B 3,085.0 / 2,979.4 KiB 37.96 ms; 5006 objects

mixed-small (28 October 2026)

$ php docs/benchmarks/memory.php --fixture=mixed-small --published --out=docs/benchmarks/results/2026-10-28-php83-mixed-small.json
[build] Same element data: yes
variant              peak      retained  median ms  objects
A raw             3.0 KiB       3.0 KiB       0.00        0
B wrappers        8.4 KiB       8.4 KiB       0.04        9
C kits           21.8 KiB       7.0 KiB       0.02        0
C kits / B wrappers: peak x2.60, retained x0.83, median time x0.56

Times on this fixture are sub-millisecond and close to timer noise. Memory still shows C's higher peak (kit objects + array) and lower retained (plain array, 0 objects). mixed-nested hashes also matched; JSON is 2026-10-28-php83-mixed-nested.json.

PHP 8.5.12 extra row (500 fields)

Memory matched PHP 8.3.30 to the reported KiB. JSON: docs/benchmarks/results/2026-10-28-php85-nested-500.json. Build medians: A 0.22 ms, B 3.03 ms, C 2.38 ms.

FormBuilder pipeline

docs/benchmarks/pipeline.php looks for an installed Drupal site (FFK_SITE, then /tmp/ffk-d12-site, then /tmp/ffk-bench/d11). On 28 October 2026 the throwaway 11.4.8 tree had Composer autoload and no sites/default/settings.php. Command that failed:

php docs/benchmarks/pipeline.php
# exit 2 — no settings.php; Drupal recommended-project at /tmp/ffk-bench/d11 is not installed

JSON: docs/benchmarks/results/2026-10-28-pipeline-skip.json. No pipeline numbers are published.

B against C

Building the form (the scenario both can do):

B wrappers C kits C relative to B
Peak memory, 500 fields 884.4 KiB 1,040.2 KiB 18% higher
Retained memory, 500 fields 883.7 KiB 549.7 KiB 38% lower
Median time, 500 fields (3 repeats) 2.87–4.54 ms 1.03–1.96 ms about 0.3–0.4×
Peak memory, 5,000 fields 8,651.0 KiB 10,111.9 KiB 17% higher
Retained memory, 5,000 fields 8,650.3 KiB 5,336.0 KiB 38% lower
Median time, 5,000 fields 26.29 ms 28.67 ms within noise this run
Objects left in the form 506 / 5,006 0
Copy of $form coupled to original yes no
serialize($form) size, 500 fields 414.8 KiB 147.4 KiB 64% smaller

What this shows:

  • Peak: C is a little higher than B. While getForm() runs, the kit objects and the finished array exist at the same time. B builds into the array directly and has no such second copy.
  • Retained: B keeps one element object per element inside the form (the ##object entry), for as long as the form exists: through the rest of the build, alter hooks, caching and rendering. C's objects are gone once getForm() returns, so what stays is a plain array, about 21% larger than A's. (That 21% is consistent with getArray() building each key as '#' . $parameter, a new string per key per element, where literal keys are shared; a micro-test showed about 256 bytes per seven-key element. Not confirmed with a profiler.)
  • Time: Wall time moves with machine load (the 4 October 2026 run was quieter). Memory ratios did not. On 500 fields C was still faster than B in every 28 October repeat. On 5,000 fields this run C and B were within noise. Both stay several times slower than A.
  • Behavior: B's array is coupled to its copies and carries objects, which is the mechanism described in #3539320. C's array is a plain array. See the comparison for what this means.

The round trip and the large-array copy question

Comment #95 asks: "Without references, conversion between render arrays and objects could involve copying a lot of large arrays. Could this be a memory issue?" The "alter" scenario measures B's actual round trip, which does use references:

  • Opening the existing 500-field form (455.8 KiB) with wrappers and editing it added 312.8 KiB at peak, against 137.5 KiB for the same edit as array code. The wrapper overhead is therefore about 175 KiB at peak, about 157 KiB retained (5,000 fields: about 1.7 MiB and 1.6 MiB). That is less than the size of the array, so the array was not copied: initializeInternalStorage() takes it by reference ($this->storage = &$element). The cost is the 506 element objects, which stay in the array as ##object.
  • The price of avoiding the copy is the reference itself: after wrapping, a plain copy of the form is still coupled to the original ("copy coupled: yes"). That is the coupling #3539320 describes.
  • A version of B without references, as comment #95 suggests, would have to copy each element into its object and back. That variant does not exist in code, so it is not measured here.
  • C has no round trip: kits only build, and the array they return is never read back into objects.

Two side effects of B observed while measuring, not costs as such:

  • serialize($form) works and unserialize() restores the same element data, but the serialized form is 2.8× the size of A's and C's, because each element object is serialized with its storage. Serializing also removes the ##object entries from the live array (RenderElementBase::__sleep() unsets them through the reference): 506 objects before, 0 after.
  • var_export() on a wrapped form warns "var_export does not handle circular references", because each ##object holds a reference to the array that contains it.

Limits

  • One machine (WSL2 i7-1360P). Primary PHP 8.3.30; one extra PHP 8.5.12 row. OPcache CLI off. The PHP 8.5 JSON records kits_tag/formfactory_tag as null (git describe was not available on the container bind mounts); memory matched the tagged PHP 8.3 primary.
  • Nested fixture still uses two element types; mixed-small adds five more kernel-free types. Entity and file kits are not in the mixed fixture.
  • B runs on a partly booted core (real element plugins, no hooks, no database). FormBuilder / form cache / AJAX / rendering were not measured: pipeline.php exited 2 (no installed site). AJAX for B is the known #3539320 failure mode and was not re-probed.
  • B is MR !13985 at 91930afa applied to 11.4.8, not a future redesign.
  • Sub-millisecond mixed-form times are timer noise. Use memory ratios there.
  • C CLI uses FormObjectInterface implementors rather than FormObjectBase, because FormObjectBase extends FormBase and needs a kernel.

Previous run (4 October 2026)

PHP 8.3.30, kits 2.0.0-beta4, formfactory 2.1.0-beta1, C via FormFactoryService::append + DetailsKit::create (not FormObjectInterface). Memory matched the 28 October run to 0.2 KiB.

500 fields (4 October 2026)

[build] Same element data: yes
variant              peak      retained  median ms     min ms     max ms  objects
A raw           464.2 KiB     455.8 KiB       0.11       0.11       0.14        0
B wrappers      884.4 KiB     883.7 KiB       1.04       0.82       2.36      506
C kits        1,040.4 KiB     549.7 KiB       0.61       0.56       0.71        0
serialize: A and C 147.4 KiB; B 414.8 KiB; B copy coupled yes
[alter] A 137.5 KiB 0.11 ms; B 312.8 / 294.7 KiB 0.87 ms; 506 objects

5,000 fields (4 October 2026)

[build] Same element data: yes
variant              peak      retained  median ms     min ms     max ms  objects
A raw         4,382.7 KiB   4,342.3 KiB       1.17       1.13       1.49        0
B wrappers    8,651.0 KiB   8,650.3 KiB      12.56       9.02      21.83     5006
C kits       10,112.2 KiB   5,336.0 KiB       6.01       5.77       7.34        0
C kits / B wrappers: peak x1.17, retained x0.62, median time x0.48
[alter] A 1,371.4 / 1,367.9 KiB 1.03 ms; B 3,085.0 / 2,979.4 KiB 10.46 ms