Users->find() * ->contain(['Roles', 'Comments']) * ->projectAs(UserDto::class) * ->all(); * ``` */ class DtoMapper { /** * Cached reflection info per class. * * @var array}> */ protected static array $cache = []; /** * Map array data to a DTO instance. * * @template T of object * @param array $data The source data (typically from ORM) * @param class-string $dtoClass The target DTO class * @return T */ public function map(array $data, string $dtoClass): object { $info = $this->getClassInfo($dtoClass); $args = []; foreach ($info['params'] as $name => $paramInfo) { // isset() is faster than array_key_exists(), check for null separately if (isset($data[$name])) { $value = $data[$name]; // Handle nested DTO (type hint is a class) - only map arrays, pass objects through if ($paramInfo['dtoClass'] !== null && is_array($value)) { $value = $this->map($value, $paramInfo['dtoClass']); } elseif ($paramInfo['collectionOf'] !== null) { // Handle collection - inline loop avoids closure creation overhead $collectionClass = $paramInfo['collectionOf']; $mapped = []; foreach ($value as $item) { $mapped[] = is_array($item) ? $this->map($item, $collectionClass) : $item; } $value = $mapped; } $args[$name] = $value; } elseif (array_key_exists($name, $data)) { // Value is explicitly null in data $args[$name] = null; } elseif ($paramInfo['hasDefault']) { $args[$name] = $paramInfo['default']; } elseif ($paramInfo['nullable']) { $args[$name] = null; } // If required and not provided, let PHP throw the error } return new $dtoClass(...$args); } /** * Get cached class info via reflection. * * @param class-string $class The class to analyze * @return array{params: array} */ protected function getClassInfo(string $class): array { if (isset(static::$cache[$class])) { return static::$cache[$class]; } $reflection = new ReflectionClass($class); $constructor = $reflection->getConstructor(); $params = []; if ($constructor !== null) { foreach ($constructor->getParameters() as $param) { $params[$param->getName()] = $this->analyzeParameter($param); } } static::$cache[$class] = ['params' => $params]; return static::$cache[$class]; } /** * Analyze a constructor parameter for DTO mapping info. * * @param \ReflectionParameter $param The parameter to analyze * @return array{name: string, nullable: bool, hasDefault: bool, default: mixed, dtoClass: class-string|null, collectionOf: class-string|null} */ protected function analyzeParameter(ReflectionParameter $param): array { $type = $param->getType(); $info = [ 'name' => $param->getName(), 'nullable' => $param->allowsNull(), 'hasDefault' => $param->isDefaultValueAvailable(), 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, 'dtoClass' => null, 'collectionOf' => null, ]; // Check if type is a class (potential nested DTO) if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { $typeName = $type->getName(); // Exclude common non-DTO classes if ( !in_array($typeName, ['DateTime', 'DateTimeImmutable', 'DateTimeInterface', 'stdClass'], true) && class_exists($typeName) ) { $info['dtoClass'] = $typeName; } } // Check for #[CollectionOf(SomeDto::class)] attribute foreach ($param->getAttributes(CollectionOf::class) as $attr) { /** @var class-string $collectionClass */ $collectionClass = $attr->getArguments()[0]; $info['collectionOf'] = $collectionClass; $info['dtoClass'] = null; // Collection takes precedence } return $info; } /** * Clear the reflection cache. * * Useful for testing or when classes are reloaded. * * @return void */ public static function clearCache(): void { static::$cache = []; } }