From 8addf56604c7efc1153eac07b62edee47d872648 Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 24 Jul 2026 19:06:12 +0200 Subject: [PATCH 1/3] Inline schema transforms into generated fast-path code 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 --- src/Generator/TransformCompiler.php | 82 +++++++++++++++++ src/Generator/TwigRenderer.php | 19 ++++ templates/dto.twig | 2 +- templates/element/optimizations.twig | 40 ++++----- tests/Generator/TransformCompilerTest.php | 102 ++++++++++++++++++++++ tests/Generator/TwigRendererTest.php | 68 +++++++++++++++ tests/TestDto/TransformDto.php | 31 +++++++ 7 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 src/Generator/TransformCompiler.php create mode 100644 tests/Generator/TransformCompilerTest.php diff --git a/src/Generator/TransformCompiler.php b/src/Generator/TransformCompiler.php new file mode 100644 index 0000000..72fd4e4 --- /dev/null +++ b/src/Generator/TransformCompiler.php @@ -0,0 +1,82 @@ +[A-Za-z_]\w*)*$/'; + + /** + * Compile a transform callable into a generated PHP expression. + * + * @param string|null $callable Transform callable from the schema + * @param string $expr Generated PHP expression to transform + * @param bool $guardNull Whether null values must bypass the transform + * @param bool $strictTypes Whether the generated file declares strict types + * + * @return string + */ + public static function compile(?string $callable, string $expr, bool $guardNull = false, bool $strictTypes = false): string + { + if ($callable === null || $callable === '') { + return $expr; + } + + // An inlined call takes its argument coercion from the generated file, while the + // runtime dispatch takes it from Dto.php, which is strict. Inline only when the + // generated file is strict as well, so scalar type hints keep raising TypeError. + if (!$strictTypes) { + return self::fallback($callable, $expr); + } + + if (!preg_match(self::CALLABLE_PATTERN, $callable)) { + return self::fallback($callable, $expr); + } + + // Only inline what resolves while generating. Anything unresolved keeps the runtime + // dispatch so a typo in the schema still surfaces as InvalidArgumentException instead + // of a raw Error from the generated code. + if (!is_callable($callable)) { + return self::fallback($callable, $expr); + } + + if ($guardNull && !preg_match(self::SIMPLE_EXPR_PATTERN, $expr)) { + return self::fallback($callable, $expr); + } + + $call = '\\' . ltrim($callable, '\\') . '(' . $expr . ')'; + + if ($guardNull) { + // Parenthesized so the expression stays safe in any surrounding context. + return '(' . $expr . ' === null ? null : ' . $call . ')'; + } + + return $call; + } + + /** + * Compile the existing runtime transform dispatch. + * + * @param string $callable Transform callable from the schema + * @param string $expr Generated PHP expression to transform + * + * @return string + */ + protected static function fallback(string $callable, string $expr): string + { + return '$this->transformValue(\'' . addcslashes($callable, "'\\") . '\', ' . $expr . ')'; + } +} diff --git a/src/Generator/TwigRenderer.php b/src/Generator/TwigRenderer.php index ff9a69d..49fc6e4 100644 --- a/src/Generator/TwigRenderer.php +++ b/src/Generator/TwigRenderer.php @@ -77,6 +77,7 @@ protected function registerFilters(): void protected function registerFunctions(): void { $this->twig->addFunction(new TwigFunction('getCollectionAdapter', [$this, 'getCollectionAdapter'])); + $this->twig->addFunction(new TwigFunction('transformExpr', [$this, 'transformExpr'], ['needs_context' => true])); } /** @@ -113,6 +114,24 @@ public function getCollectionAdapter(string $collectionType): CollectionAdapterI return CollectionAdapterRegistry::getOrDefault($collectionType); } + /** + * Compile a field transform into a generated PHP expression. + * + * Reads `strictTypes` from the render context because inlining is only safe in a + * generated file that declares strict types. + * + * @param array $context + * @param string|null $callable Transform callable from the schema + * @param string $expr Generated PHP expression to transform + * @param bool $guardNull Whether null values must bypass the transform + * + * @return string + */ + public function transformExpr(array $context, ?string $callable, string $expr, bool $guardNull = false): string + { + return TransformCompiler::compile($callable, $expr, $guardNull, (bool)($context['strictTypes'] ?? false)); + } + /** * Set global configuration variables. * diff --git a/templates/dto.twig b/templates/dto.twig index 6d3bbe9..6a7cdd3 100644 --- a/templates/dto.twig +++ b/templates/dto.twig @@ -38,7 +38,7 @@ class {{ className }} extends {% if extends starts with '\\' %}{{ extends[1:] | {% include 'element/properties.twig' with {'fields': fields, 'readonlyProperties': readonlyProperties|default(false)} only -%} {% include 'element/metadata.twig' with {'metaData': metaData} only %} {% include 'element/map.twig' with {'fields': fields} only %} -{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false)} only %} +{% include 'element/optimizations.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false), 'strictTypes': strictTypes|default(false)} only %} {% include 'element/methods.twig' with {'fields': fields, 'immutable': immutable, 'readonlyProperties': readonlyProperties|default(false), 'scalarAndReturnTypes': scalarAndReturnTypes} only -%} {% include 'element/array_shape.twig' with {'arrayShape': arrayShape} only %} } diff --git a/templates/element/optimizations.twig b/templates/element/optimizations.twig index db6e220..0aa5f7a 100644 --- a/templates/element/optimizations.twig +++ b/templates/element/optimizations.twig @@ -25,7 +25,7 @@ {% if field.lazy|default(false) %} if (array_key_exists('{{ field.name }}', $data)) { {% if field.transformFrom %} - $this->_lazyData['{{ field.name }}'] = $this->transformValue('{{ field.transformFrom }}', $data['{{ field.name }}']); + $this->_lazyData['{{ field.name }}'] = {{ transformExpr(field.transformFrom, "$data['" ~ field.name ~ "']", true)|raw }}; {% else %} $this->_lazyData['{{ field.name }}'] = $data['{{ field.name }}']; {% endif %} @@ -35,7 +35,7 @@ if (isset($data['{{ field.name }}'])) { $value = $data['{{ field.name }}']; {% if field.transformFrom %} - $value = $this->transformValue('{{ field.transformFrom }}', $value); + $value = {{ transformExpr(field.transformFrom, '$value', false)|raw }}; {% endif %} if (is_array($value)) { $value = new {{ field.typeHint }}($value); @@ -52,7 +52,7 @@ $items = $data['{{ field.name }}']; foreach ($items as $key => $item) { {% if field.transformFrom %} - $item = $this->transformValue('{{ field.transformFrom }}', $item); + $item = {{ transformExpr(field.transformFrom, '$item', true)|raw }}; {% endif %} {% if field.singularClass is defined and field.singularClass %} if (is_array($item)) { @@ -82,7 +82,7 @@ $items = $data['{{ field.name }}']; foreach ($items as $key => $item) { {% if field.transformFrom %} - $item = $this->transformValue('{{ field.transformFrom }}', $item); + $item = {{ transformExpr(field.transformFrom, '$item', true)|raw }}; {% endif %} if (is_array($item)) { $item = new {{ field.singularClass }}($item); @@ -96,7 +96,7 @@ if (isset($data['{{ field.name }}'])) { $value = $data['{{ field.name }}']; {% if field.transformFrom %} - $value = $this->transformValue('{{ field.transformFrom }}', $value); + $value = {{ transformExpr(field.transformFrom, '$value', false)|raw }}; {% endif %} if (!$value instanceof \UnitEnum) { {% if field.enum == 'unit' %} @@ -112,7 +112,7 @@ if (isset($data['{{ field.name }}'])) { $value = $data['{{ field.name }}']; {% if field.transformFrom %} - $value = $this->transformValue('{{ field.transformFrom }}', $value); + $value = {{ transformExpr(field.transformFrom, '$value', false)|raw }}; {% endif %} if (!$value instanceof {{ field.typeHint }}) { {% if '::' in field.factory %} @@ -129,7 +129,7 @@ if (isset($data['{{ field.name }}'])) { $value = $data['{{ field.name }}']; {% if field.transformFrom %} - $value = $this->transformValue('{{ field.transformFrom }}', $value); + $value = {{ transformExpr(field.transformFrom, '$value', false)|raw }}; {% endif %} if (!$value instanceof {{ field.typeHint }}) { $value = new {{ field.typeHint }}($value); @@ -142,7 +142,7 @@ /** @var {{ field.docBlockType ?? field.typeHint }}{% if field.nullable %}|null{% endif %} $value */ $value = $data['{{ field.name }}']; {% if field.transformFrom %} - $value = $this->transformValue('{{ field.transformFrom }}', $value); + $value = {{ transformExpr(field.transformFrom, '$value', false)|raw }}; {% endif %} $this->{{ field.name }} = $value; $this->_touchedFields['{{ field.name }}'] = true; @@ -163,20 +163,20 @@ {% set key = field.mapTo is not null ? field.mapTo : field.name %} {% if field.lazy|default(false) %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null)), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->_lazyData['" ~ field.name ~ "'] ?? ($this->" ~ field.name ~ " !== null ? " ~ (field.dto ? "$this->" ~ field.name ~ "->toArray()" : (field.collection and field.collectionType != 'array' ? "(static function (\\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \\PhpCollective\\Dto\\Dto\\Dto ? $v->toArray() : $v; } return $r; })($this->" ~ field.name ~ ")" : "$this->" ~ field.name)) ~ " : null)", true)|raw }}, {% else %} '{{ key }}' => $this->_lazyData['{{ field.name }}'] ?? ($this->{{ field.name }} !== null ? {% if field.dto %}$this->{{ field.name }}->toArray(){% elseif field.collection and field.collectionType != 'array' %}(static function (\Traversable $c): array { $r = []; foreach ($c as $k => $v) { $r[$k] = is_object($v) && $v instanceof \PhpCollective\Dto\Dto\Dto ? $v->toArray() : $v; } return $r; })($this->{{ field.name }}){% else %}$this->{{ field.name }}{% endif %} : null), {% endif %} {% elseif field.dto %} {% if field.nullable %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? $this->" ~ field.name ~ "->toArray() : null", true)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null, {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->toArray()), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->toArray()", false)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}->toArray(), {% endif %} @@ -261,13 +261,13 @@ {% if field.nullable %} {% if field.enum == 'unit' %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}?->name), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "?->name", true)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}?->name, {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}?->value), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "?->value", true)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}?->value, {% endif %} @@ -275,13 +275,13 @@ {% else %} {% if field.enum == 'unit' %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->name), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->name", false)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}->name, {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->value), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->value", false)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}->value, {% endif %} @@ -290,13 +290,13 @@ {% elseif field.isClass and field.serialize == 'string' %} {% if field.nullable %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? (string)$this->{{ field.name }} : null), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? (string)$this->" ~ field.name ~ " : null", true)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }} !== null ? (string)$this->{{ field.name }} : null, {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', (string)$this->{{ field.name }}), + '{{ key }}' => {{ transformExpr(field.transformTo, "(string)$this->" ~ field.name, false)|raw }}, {% else %} '{{ key }}' => (string)$this->{{ field.name }}, {% endif %} @@ -304,20 +304,20 @@ {% elseif field.isClass and (field.serialize == 'array' or field.serialize == 'FromArrayToArray') %} {% if field.nullable %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ " !== null ? $this->" ~ field.name ~ "->toArray() : null", true)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }} !== null ? $this->{{ field.name }}->toArray() : null, {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}->toArray()), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name ~ "->toArray()", false)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}->toArray(), {% endif %} {% endif %} {% else %} {% if field.transformTo %} - '{{ key }}' => $this->transformValue('{{ field.transformTo }}', $this->{{ field.name }}), + '{{ key }}' => {{ transformExpr(field.transformTo, "$this->" ~ field.name, field.nullable)|raw }}, {% else %} '{{ key }}' => $this->{{ field.name }}, {% endif %} diff --git a/tests/Generator/TransformCompilerTest.php b/tests/Generator/TransformCompilerTest.php new file mode 100644 index 0000000..415d74a --- /dev/null +++ b/tests/Generator/TransformCompilerTest.php @@ -0,0 +1,102 @@ +assertSame($expected, TransformCompiler::compile($callable, $expr, $guardNull, true)); + } + + public function testCompileWithoutStrictTypesFallsBack(): void + { + $callable = TransformHelper::class . '::normalizeEmail'; + $expected = '$this->transformValue(\'' . str_replace('\\', '\\\\', $callable) . '\', $value)'; + + $this->assertSame($expected, TransformCompiler::compile($callable, '$value', false, false)); + } + + /** + * @return array + */ + public static function compileProvider(): array + { + $helper = TransformHelper::class; + + return [ + 'null callable' => [null, '$value', false, '$value'], + 'empty callable' => ['', '$value', false, '$value'], + 'plain function' => ['trim', '$value', false, '\trim($value)'], + 'static method' => [ + $helper . '::normalizeEmail', + '$value', + false, + '\\' . $helper . '::normalizeEmail($value)', + ], + 'leading backslash' => [ + '\\' . $helper . '::normalizeEmail', + '$value', + false, + '\\' . $helper . '::normalizeEmail($value)', + ], + 'guard null true' => [ + $helper . '::normalizeEmail', + '$value', + true, + '($value === null ? null : \\' . $helper . '::normalizeEmail($value))', + ], + 'guard null false with non-simple expression' => [ + $helper . '::normalizeEmail', + '$this->value()', + false, + '\\' . $helper . '::normalizeEmail($this->value())', + ], + 'guard null true with non-simple expression falls back' => [ + $helper . '::normalizeEmail', + '$this->value()', + true, + '$this->transformValue(\'' . str_replace('\\', '\\\\', $helper) . '::normalizeEmail\', $this->value())', + ], + 'unresolvable callable falls back' => [ + 'App\\Transform\\Email::normalizeTypo', + '$value', + false, + '$this->transformValue(\'App\\\\Transform\\\\Email::normalizeTypo\', $value)', + ], + 'unresolvable function falls back' => [ + 'trimx', + '$value', + false, + '$this->transformValue(\'trimx\', $value)', + ], + 'injection-shaped function call' => [ + "foo(); echo 'x'", + '$value', + false, + '$this->transformValue(\'foo(); echo \\\'x\\\'\', $value)', + ], + 'injection-shaped static method' => [ + 'Foo::bar; //', + '$value', + false, + '$this->transformValue(\'Foo::bar; //\', $value)', + ], + 'trailing whitespace' => ['trim ', '$value', false, '$this->transformValue(\'trim \', $value)'], + 'fallback escapes backslashes and quotes' => [ + "Foo\\Bar::baz'qux", + '$value', + false, + '$this->transformValue(\'Foo\\\\Bar::baz\\\'qux\', $value)', + ], + ]; + } +} diff --git a/tests/Generator/TwigRendererTest.php b/tests/Generator/TwigRendererTest.php index 14e3ba1..fcb4cab 100644 --- a/tests/Generator/TwigRendererTest.php +++ b/tests/Generator/TwigRendererTest.php @@ -5,6 +5,7 @@ namespace PhpCollective\Dto\Test\Generator; use PhpCollective\Dto\Generator\TwigRenderer; +use PhpCollective\Dto\Test\TestDto\TransformHelper; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -87,6 +88,73 @@ public function testUnderscorePrefixedFieldGeneratesCorrectMethodNames(): void $this->assertStringNotContainsString('with_joinData', $withOutput); } + public function testOptimizationsInlineTransformCalls(): void + { + $helper = TransformHelper::class; + $this->renderer->set(self::optimizationsData($helper . '::normalizeEmail', $helper . '::maskEmail')); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$value = \\' . $helper . '::normalizeEmail($value);', $output); + $this->assertStringContainsString('\\' . $helper . '::maskEmail($this->email)', $output); + $this->assertStringNotContainsString('transformValue(', $output); + } + + public function testOptimizationsDoNotInlineWithoutStrictTypes(): void + { + $helper = TransformHelper::class; + $this->renderer->set(self::optimizationsData($helper . '::normalizeEmail', $helper . '::maskEmail', false)); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$this->transformValue(', $output); + $this->assertStringNotContainsString('$value = \\' . $helper . '::normalizeEmail($value);', $output); + } + + public function testOptimizationsFallBackToRuntimeTransformForUnsafeCallable(): void + { + $this->renderer->set(self::optimizationsData("foo(); echo 'x'", null)); + + $output = $this->renderer->generate('element/optimizations'); + + $this->assertStringContainsString('$this->transformValue(\'foo(); echo \\\'x\\\'\', $value)', $output); + } + + /** + * @param string|null $transformFrom + * @param string|null $transformTo + * @param bool $strictTypes + * + * @return array + */ + private static function optimizationsData(?string $transformFrom, ?string $transformTo, bool $strictTypes = true): array + { + return [ + 'immutable' => false, + 'strictTypes' => $strictTypes, + 'fields' => [ + [ + 'name' => 'email', + 'type' => 'string', + 'nullable' => true, + 'typeHint' => 'string', + 'nullableTypeHint' => '?string', + 'docBlockType' => 'string', + 'collection' => false, + 'collectionType' => null, + 'dto' => false, + 'isClass' => false, + 'enum' => null, + 'factory' => null, + 'serialize' => null, + 'mapTo' => null, + 'transformFrom' => $transformFrom, + 'transformTo' => $transformTo, + ], + ], + ]; + } + public function testRegularFieldGeneratesCorrectMethodNames(): void { // Verify regular fields still work correctly diff --git a/tests/TestDto/TransformDto.php b/tests/TestDto/TransformDto.php index 09aedc1..78c7f7a 100644 --- a/tests/TestDto/TransformDto.php +++ b/tests/TestDto/TransformDto.php @@ -8,6 +8,11 @@ class TransformDto extends AbstractDto { + /** + * @var bool + */ + protected const HAS_FAST_PATH = true; + protected ?string $email = null; /** @@ -62,6 +67,32 @@ public function hasEmail(): bool return $this->email !== null; } + /** + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void + { + if (isset($data['email'])) { + /** @var string|null $value */ + $value = $data['email']; + $value = TransformHelper::normalizeEmail($value); + $this->email = $value; + $this->_touchedFields['email'] = true; + } + } + + /** + * @return array + */ + protected function toArrayFast(): array + { + return [ + 'email' => ($this->email === null ? null : TransformHelper::maskEmail($this->email)), + ]; + } + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { return $this->_toArrayInternal($type, $fields, $touched); From 762f007390d18eac0706158a1d9e590e99472a7e Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Fri, 24 Jul 2026 19:07:01 +0200 Subject: [PATCH 2/3] Document transform compilation in the performance guide --- docs/guide/performance.md | 41 ++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/docs/guide/performance.md b/docs/guide/performance.md index 844203f..fdaea6d 100644 --- a/docs/guide/performance.md +++ b/docs/guide/performance.md @@ -53,7 +53,34 @@ opcache.max_accelerated_files=20000 opcache.validate_timestamps=0 ; Disable in production ``` -### 2. Prefer touchedToArray() for Partial Data +### 2. Enable strictTypes so Transforms Get Compiled + +Fields using `transformFrom` / `transformTo` are compiled into a direct call in the generated +fast-path methods: + +``` php +// generated with strictTypes enabled +$value = \App\Transform\Email::normalize($value); + +// generated without it +$value = $this->transformValue('App\Transform\Email::normalize', $value); +``` + +The runtime form pays a method call, a null check, an `is_callable()` lookup and a dynamic +invocation for every transformed field on every hydration and serialization - roughly 4x the +cost of the direct call. + +Inlining requires `strictTypes`, because an inlined call takes its argument coercion from the +generated file rather than from the library, and dropping strict types there would silently +change how scalar type hints on your transform callables behave. + +::: tip +A transform that cannot be resolved while generating (typo, class not autoloadable at bake +time) keeps the runtime form on purpose, so the error still surfaces as +`InvalidArgumentException` instead of a raw `Error`. +::: + +### 3. Prefer touchedToArray() for Partial Data ```php // Slower: serializes all fields @@ -65,7 +92,7 @@ $data = $dto->touchedToArray(); Especially important for DTOs with many optional fields. -### 3. Use ignoreMissing for Partial Input +### 4. Use ignoreMissing for Partial Input ```php // Slower: validates every field exists @@ -75,7 +102,7 @@ $dto = new UserDto($partialData); $dto = new UserDto($partialData, ignoreMissing: true); ``` -### 4. Batch Operations +### 5. Batch Operations When processing many items, create DTOs in batches: @@ -89,7 +116,7 @@ foreach ($items as $item) { $dtos = array_map(fn($item) => new ItemDto($item), $items); ``` -### 5. Avoid Unnecessary Cloning +### 6. Avoid Unnecessary Cloning ```php // Unnecessary clone @@ -103,7 +130,7 @@ $original->setName('New'); $modified = $original->withName('New'); ``` -### 6. Use Appropriate Collection Types +### 7. Use Appropriate Collection Types ```php // ArrayObject: Good for iteration, modification @@ -115,7 +142,7 @@ $modified = $original->withName('New'); For read-heavy workloads, array collections are slightly faster. -### 7. Minimize Nested Depth +### 8. Minimize Nested Depth Deeply nested DTOs have cumulative overhead: @@ -127,7 +154,7 @@ $order->getCustomer()->getAddress()->getCity()->getName(); $order->getCustomerCity(); // Computed during creation ``` -### 8. Use Built-in Lazy DTO Fields When Nested Data Is Optional +### 9. Use Built-in Lazy DTO Fields When Nested Data Is Optional php-collective/dto supports lazy DTO and collection fields via `asLazy()`, which lets you defer nested hydration until the data is actually accessed: From 808670156f493d2bc5e15433418e599844ccc64d Mon Sep 17 00:00:00 2001 From: Mark Scherer Date: Mon, 27 Jul 2026 15:40:20 +0200 Subject: [PATCH 3/3] Short-circuit guarded non-simple transforms before resolving the callable Avoids a needless is_callable() autoload for expressions that will fall back to runtime dispatch anyway. --- src/Generator/TransformCompiler.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Generator/TransformCompiler.php b/src/Generator/TransformCompiler.php index 72fd4e4..8096346 100644 --- a/src/Generator/TransformCompiler.php +++ b/src/Generator/TransformCompiler.php @@ -46,6 +46,12 @@ public static function compile(?string $callable, string $expr, bool $guardNull return self::fallback($callable, $expr); } + // A guarded, non-simple expression cannot be inlined without evaluating it twice, so + // fall back before resolving the callable to avoid a needless autoload. + if ($guardNull && !preg_match(self::SIMPLE_EXPR_PATTERN, $expr)) { + return self::fallback($callable, $expr); + } + // Only inline what resolves while generating. Anything unresolved keeps the runtime // dispatch so a typo in the schema still surfaces as InvalidArgumentException instead // of a raw Error from the generated code. @@ -53,10 +59,6 @@ public static function compile(?string $callable, string $expr, bool $guardNull return self::fallback($callable, $expr); } - if ($guardNull && !preg_match(self::SIMPLE_EXPR_PATTERN, $expr)) { - return self::fallback($callable, $expr); - } - $call = '\\' . ltrim($callable, '\\') . '(' . $expr . ')'; if ($guardNull) {