Skip to content

Generated transformers instead of a generated middleware - #214

Closed
DavidBadura wants to merge 2 commits into
generated-middlewarefrom
generated-transformer
Closed

DavidBadura wants to merge 2 commits into
generated-middlewarefrom
generated-transformer

Conversation

@DavidBadura

@DavidBadura DavidBadura commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

This reworks the generated code from #150 on top of a new extension point instead of a middleware plus decorator.

Transforming is no longer a middleware but the core of the StackHydrator. Middlewares wrap around it, and at the end of the stack the hydrator maps the data with the ClassTransformer of the class. The hydrator gets a single ClassTransformerFactory, the builder composes it from everything registered with StackHydratorBuilder::addTransformerFactory() in a ChainTransformerFactory. The first transformer wins, the ReflectionTransformer is the fallback. When no middleware runs for a class (none registered or all of them skip it), the hydrator calls the transformer directly without building the stack. That fast path is generic, so the reflection path profits from it as well.

Because of that the CoreExtension no longer registers a middleware, a hydrator without middlewares is valid and a class which every middleware skips is simply transformed. A TransformMiddleware passed in by the user still ends the stack like before (middlewares after it were never called anyway). MissingMiddlewares, AllMiddlewaresSkipped and Extension::PRIORITY_TRANSFORM are deprecated since they can not happen or have no effect anymore.

The generated extension is now just a transformer factory. It loads one generated class per hydrated class, nested classes are still inlined. The file name contains the fingerprints of the class and its nested classes, so a changed class has no matching code and simply falls back to reflection instead of throwing OutdatedGeneratedMiddleware. Code is generated on first use with autoGenerate: true (dev), or ahead of time with the TransformerCompiler, which takes $builder->metadataFactory() so the code matches the metadata of all extensions:

$builder = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->useExtension(new GeneratedTransformerExtension($cachePath));

(new TransformerCompiler($builder->metadataFactory(), $cachePath))->compile([ProfileCreated::class]);

With that the class list is gone from the runtime config, and so are GeneratedHydrator, GeneratedDecorator, GeneratedMiddlewareSlot, GeneratedMiddleware, HydratorNotSet and GeneratedMiddlewareNotLoaded. HydratorDecorator::decorate() only receives the hydrator to wrap, and build() works again with the generated extension. The root hydrator for nested objects from the base branch is kept as is, so tracing still sees nested objects and disables inlining.

The extracted call stack is shared by all transformers of a hydrator, so circular references are still reported with the whole chain.

Benchmarks against the current state of #150 (PHP 8.5, opcache, 10 iterations, the runs were a bit noisy):

Benchmark Subject before after diff
StackHydratorBench hydrate 1000 objects 1.992ms 1.345ms -32%
StackHydratorBench extract 1000 objects 1.498ms 1.137ms -24%
StackHydratorWithCryptographyBench hydrate 1000 objects 2.470ms 2.296ms -7%
StackHydratorWithCryptographyBench extract 1000 objects 4.474ms 4.255ms -5%
GeneratedHydratorBench hydrate 1000 objects 645μs 655μs +1%
GeneratedHydratorBench extract 1000 objects 343μs 363μs +6%
GeneratedHydratorWithCryptographyBench hydrate 1000 objects 1.543ms 1.562ms +1%
GeneratedHydratorWithCryptographyBench extract 1000 objects 3.413ms 3.526ms +3%
StackHydratorWithLazyBench hydrate 1000 objects (proxy only) 252μs 269μs +7%
StackHydratorWithLazyBench hydrate 1000 objects (initialized) 1.825ms 1.920ms +5%

The generated path stays roughly where the decorator version was, extract is a few percent slower since the generated extract() delegates to the method of the root class. Creating lazy proxies got a bit slower because of the extra lookup for the direct transformer at the start of hydrate().

The TransformMiddleware now delegates to a ClassTransformer per class, which
the hydrator gets from the transformer factories registered on the builder.
Reflection stays the default, the generated extension only registers a factory
which loads one generated transformer per class. When no other middleware runs
for a class, the StackHydrator calls its transformer directly. This replaces
the GeneratedHydrator decorator and the middleware slot, and also speeds up
the reflection path.

The name of a generated transformer contains the fingerprints of the class and
its nested classes, so a changed class falls back to reflection instead of
throwing. The code is generated on first use with autoGenerate, or ahead of
time with the TransformerCompiler.
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Hello 👋

Here are the most recent benchmark results, compared against generated-middleware. Time is the mode over the iterations in the its column, with the relative standard deviation in brackets.

GeneratedHydrator

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb -21.24% 0.786μs (±29.20%) 0.763μs (±0.95%) -2.86%
benchExtract1Object 1000 5 1.855mb 0.00% 0.890μs (±23.54%) 0.866μs (±1.56%) -2.76%
benchHydrate1000Objects 3 5 1.855mb 0.00% 776.224μs (±26.26%) 745.285μs (±2.13%) -3.99%
benchExtract1000Objects 3 5 1.855mb 0.00% 416.633μs (±1.07%) 430.789μs (±0.45%) +3.40%

StackHydrator

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 2.215μs (±0.81%) 1.657μs (±1.94%) -25.22%
benchExtract1Object 1000 5 1.855mb 0.00% 2.588μs (±0.47%) 2.070μs (±1.49%) -20.01%
benchHydrate1000Objects 3 5 1.855mb 0.00% 2.182ms (±1.07%) 1.648ms (±12.24%) -24.49%
benchExtract1000Objects 3 5 1.855mb 0.00% 2.096ms (±0.65%) 1.595ms (±0.38%) -23.93%

StackHydratorWithLazy

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 0.387μs (±0.76%) 0.411μs (±1.46%) +6.34%
benchHydrate1ObjectTriggerInit 1000 5 1.855mb 0.00% 2.807μs (±1.36%) 2.907μs (±0.84%) +3.55%
benchHydrate1000Objects 3 5 1.855mb 0.00% 373.854μs (±0.83%) 407.736μs (±2.36%) +9.06%
benchHydrate1000ObjectsTriggerInit 3 5 1.855mb 0.00% 2.773ms (±0.64%) 2.819ms (±0.44%) +1.65%

StackHydratorWithCryptography

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 3.404μs (±0.69%) 3.076μs (±0.83%) -9.65%
benchExtract1Object 1000 5 1.855mb 0.00% 8.533μs (±0.58%) 8.317μs (±0.62%) -2.53%
benchHydrate1000Objects 3 5 1.855mb 0.00% 3.283ms (±0.31%) 3.019ms (±0.29%) -8.05%
benchExtract1000Objects 3 5 1.855mb 0.00% 8.032ms (±1.49%) 7.732ms (±0.93%) -3.73%

GeneratedHydratorWithCryptography

subject revs its memory base current diff
benchHydrate1Object 1000 5 1.855mb 0.00% 2.126μs (±2.51%) 2.087μs (±0.74%) -1.84%
benchExtract1Object 1000 5 1.855mb 0.00% 6.910μs (±1.42%) 6.892μs (±0.75%) -0.27%
benchHydrate1000Objects 3 5 1.855mb 0.00% 2.099ms (±0.42%) 2.034ms (±6.51%) -3.12%
benchExtract1000Objects 3 5 1.855mb 0.00% 6.418ms (±0.84%) 6.322ms (±1.62%) -1.50%

This comment gets updated every time a new commit comes in.

The TransformMiddleware only connected the stack to the transformer, so the
hydrator now ends every stack with it on its own and gets a single transformer
factory, which the builder composes from the registered factories. The
CoreExtension no longer registers a middleware, a hydrator without middlewares
is valid and a class which all middlewares skip is only transformed. A
TransformMiddleware passed by the user still ends the stack like before.

MissingMiddlewares, AllMiddlewaresSkipped and PRIORITY_TRANSFORM are deprecated
since they can not happen or have no effect anymore.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant