Inline schema transforms into generated fast-path code - #115
Conversation
Fields with transformFrom/transformTo went through `$this->transformValue($callable, $value)` in the generated fast-path methods. Per transformed field, per hydration and per serialization, that costs a method call, a null check, an `is_callable()` string lookup and a dynamic invocation. Measured on this repo: 300k calls take 143.7ms that way versus 35.2ms as a direct call, a factor of 4. The transform name is known while generating, so `TransformCompiler` now emits the direct call instead. End to end on a single-field transform DTO this is roughly 1.9x on hydration and 1.7x on serialization. Inlining only happens when all of the following hold, otherwise the generated code keeps the existing runtime dispatch: - the callable is a plain identifier, namespaced function or static method (strict pattern, since the schema string ends up in generated PHP) - it resolves at generation time, so a typo still surfaces as InvalidArgumentException rather than a raw Error - the generated file declares strict types, since an inlined call takes its argument coercion from the calling file rather than from Dto.php - null-guarded sites only inline for side-effect-free expressions, so the value is never evaluated twice
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #115 +/- ##
============================================
+ Coverage 83.07% 83.25% +0.17%
- Complexity 1555 1581 +26
============================================
Files 45 46 +1
Lines 3835 3881 +46
============================================
+ Hits 3186 3231 +45
- Misses 649 650 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR optimizes generated DTO fast-path hydration/serialization by compiling schema transformFrom / transformTo callables into direct PHP calls when it’s safe (notably when the generated file declares strict_types=1), keeping the existing runtime dispatch as a fallback for unsafe/unresolvable cases.
Changes:
- Add
TransformCompilerand atransformExpr()Twig function to emit either an inlined call (e.g.,\Cls::method($x)) or the existing$this->transformValue(...)fallback. - Update the optimizations template and DTO template wiring to pass
strictTypesinto the render context for safe inlining decisions. - Add unit/template tests and update performance documentation to describe the strictTypes gating and fallback behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/TestDto/TransformDto.php | Updates generated test DTO fixture to include fast-path methods using direct transform calls. |
| tests/Generator/TwigRendererTest.php | Adds template-level assertions for inlined vs fallback transform rendering based on strictTypes and callable safety. |
| tests/Generator/TransformCompilerTest.php | Adds unit coverage for transform compilation decisions (inline vs fallback) including injection-shaped and unresolvable callables. |
| templates/element/optimizations.twig | Replaces runtime transformValue() calls with transformExpr() so transforms can inline where safe. |
| templates/dto.twig | Threads strictTypes into the optimizations include context (with only) so transformExpr() can make the correct decision. |
| src/Generator/TwigRenderer.php | Registers the new Twig function and exposes strictTypes in global render vars from config. |
| src/Generator/TransformCompiler.php | Implements callable validation + safe inlining logic, with fallback to existing runtime dispatch. |
| docs/guide/performance.md | Documents the strictTypes requirement and performance rationale for transform inlining. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…able Avoids a needless is_callable() autoload for expressions that will fall back to runtime dispatch anyway.
Fields declaring
transformFrom/transformTowent through$this->transformValue($callable, $value)inside the generated fast-path methods. Per transformed field, on every hydration and every serialization, that costs a method call, a null check, anis_callable()string lookup and a dynamic invocation.Measured in this repo (PHP 8.5 CLI, JIT off, 300k calls): 143.7ms through
transformValue()versus 35.2ms for the equivalent direct call, so about 4x the cost. The transform name is known while generating, so there is no reason to resolve it at runtime.TransformCompilernow compiles the transform into the generated code:End to end on a single-field transform DTO that is roughly 1.9x on hydration and 1.7x on serialization. Wider DTOs with several transformed fields gain proportionally more.
When inlining is skipped
Correctness first: any site that cannot be inlined safely keeps the existing runtime dispatch. The conditions are all of:
App\Transform\Email::normalizeTypostays on the runtime path so it still surfaces asInvalidArgumentExceptionrather than a rawErrorfrom generated code.transformValue()was always called fromDto.php, which is strict. Without this gate, scalar type hints on user transforms would silently start coercing instead of raisingTypeError.toArray()expressions and the lazy??chains therefore stay on the runtime path.Notes
transformValue()is untouched and still used by the non-fast paths and by every fallback above.TwigRenderer::transformExpr()readsstrictTypesfrom the render context, so the templates do not have to thread the flag through 20 call sites.element/optimizationsand assert the inlined form appears withstrictTypeson and does not without it.