Repository: spatie/data-transfer-object (php)

Issue:
Automatic casting of nested array DTOs doesn't work with DataTransferObjectCollection
```php
class TagData extends DataTransferObject
{
   public string $name;
}

class TagDataCollection extends DataTransferObjectCollection
{
    public function current(): TagData
    {
        return parent::current();
    }
}

class PostData extends DataTransferObject
{
   public TagDataCollection $tags;
}

$postData = new PostData([
    'tags' => [
        ['name' => 'foo'],
        ['name' => 'bar']
    ]
]);
```

Would you be open for a PR?

Pull request description:
Cast nested collections
A bit more code changes than I would of liked, but here goes:

* Will cast to `DataTransferObjectCollection` objects instead of an array if typed by the parent `DataTransferObject`
* Supports docblock definition of the collection class (will use the first if multiple types are defined)
* Foundation for PHP 8 Union types
* Throws an exception if type is not specified for a collection class

Fixes #116 

Patch:
diff --git a/README.md b/README.md
index 1b8d9fa..67fb40e 100644
--- a/README.md
+++ b/README.md
@@ -245,7 +245,7 @@ Alternatively you can also use a phpdoc for this:
 use Spatie\DataTransferObject\DataTransferObjectCollection;
 
 /**
- * @method PostData current
+ * @method \App\DataTransferObjects\PostData current
  */
 class PostCollection extends DataTransferObjectCollection
 {
diff --git a/src/DataTransferObjectError.php b/src/DataTransferObjectError.php
index 7e32c06..e8cb6e9 100644
--- a/src/DataTransferObjectError.php
+++ b/src/DataTransferObjectError.php
@@ -64,4 +64,9 @@ class DataTransferObjectError extends TypeError
     {
         return new self("Cannot change the value of property {$property} on an immutable data transfer object");
     }
+
+    public static function untypedCollection(string $class): DataTransferObjectError
+    {
+        return new self("Collection class `{$class}` has no defined array type.");
+    }
 }
diff --git a/src/DocblockFieldValidator.php b/src/DocblockFieldValidator.php
index 6fb5dc0..114c584 100644
--- a/src/DocblockFieldValidator.php
+++ b/src/DocblockFieldValidator.php
@@ -4,25 +4,28 @@ declare(strict_types=1);
 
 namespace Spatie\DataTransferObject;
 
+use RecursiveArrayIterator;
+use RecursiveIteratorIterator;
+
 class DocblockFieldValidator extends FieldValidator
 {
     public const DOCBLOCK_REGEX = <<<REGEXP
         /
         @var (                               # Starting with `@var `, we'll capture the definition the follows
-        
+
              (?:                             # Not explicitly capturing this group,
                                              # which contains repeated sets of type definitions
-        
+
                  (?:                         # Not explicitly capturing this group
                      [\w?|\\\\<>,\s]         # Matches type definitions like `int|string|\My\Object|array<int, string>`
                  )+                          # These definitions can be repeated
-        
+
                  (?:                         # Not explicitly capturing this group
                      \[]                     # Matches array definitions like `int[]`
                  )?                          # Array definitions are optional though
-        
+
              )+                              # Repeated sets of type definitions
-        
+
         )                                    # The whole definition after `@var ` is captured in one group
         /x
 REGEXP;
@@ -85,7 +88,8 @@ REGEXP;
 
     private function resolveAllowedArrayTypes(string $definition): array
     {
-        return $this->normaliseTypes(...array_map(
+        // Iterators flatten the array for multiple return types from DataTransferObjectCollection::current
+        return $this->normaliseTypes(...(new RecursiveIteratorIterator(new RecursiveArrayIterator(array_map(
             function (string $type) {
                 if (! $type) {
                     return;
@@ -101,10 +105,14 @@ REGEXP;
                     return $matches[1];
                 }
 
+                if (is_subclass_of($type, DataTransferObjectCollection::class)) {
+                    return $this->resolveAllowedArrayTypesFromCollection($type);
+                }
+
                 return null;
             },
             explode('|', $definition)
-        ));
+        )))));
     }
 
     private function resolveAllowedArrayKeyTypes(string $definition): array
@@ -122,12 +130,4 @@ REGEXP;
             explode('|', $definition)
         ));
     }
-
-    private function normaliseTypes(?string ...$types): array
-    {
-        return array_filter(array_map(
-            fn (?string $type) => self::$typeMapping[$type] ?? $type,
-            $types
-        ));
-    }
 }
diff --git a/src/FieldValidator.php b/src/FieldValidator.php
index 8f527a9..76ec5bd 100644
--- a/src/FieldValidator.php
+++ b/src/FieldValidator.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Spatie\DataTransferObject;
 
+use ReflectionClass;
 use ReflectionProperty;
 
 abstract class FieldValidator
@@ -148,4 +149,63 @@ abstract class FieldValidator
     {
         return $value instanceof $type || gettype($value) === $type;
     }
+
+    protected function resolveAllowedArrayTypesFromCollection(string $type): array
+    {
+        if (! is_subclass_of($type, DataTransferObjectCollection::class)) {
+            return [];
+        }
+
+        $class = new ReflectionClass($type);
+
+        $currentReturnType = $class->getMethod('current')->getReturnType();
+
+        // We cast to array to support future union types in PHP 8
+        $currentReturnTypes = [];
+        if ($currentReturnType) {
+            $currentReturnTypes[] = $currentReturnType->getName();
+        }
+
+        $docblockReturnTypes = $class->getDocComment()
+            ? $this->getCurrentReturnTypesFromDocblock($class->getDocComment())
+            : [];
+
+        $types = [...$currentReturnTypes, ...$docblockReturnTypes];
+
+        if (! $types) {
+            throw DataTransferObjectError::untypedCollection($type);
+        }
+
+        return $this->normaliseTypes(...$types);
+    }
+
+    /**
+     * @return string[]
+     */
+    private function getCurrentReturnTypesFromDocblock(string $definition): array
+    {
+        $DOCBLOCK_REGEX = '/@method ((?:(?:[\w?|\\\\<>])+(?:\[])?)+) current/';
+
+        preg_match(
+            $DOCBLOCK_REGEX,
+            $definition,
+            $matches
+        );
+
+        $type = $matches[1] ?? null;
+
+        if (! $type) {
+            return null;
+        }
+
+        return explode('|', $type);
+    }
+
+    protected function normaliseTypes(?string ...$types): array
+    {
+        return array_filter(array_map(
+            fn (?string $type) => self::$typeMapping[$type] ?? $type,
+            $types
+        ));
+    }
 }
diff --git a/src/PropertyFieldValidator.php b/src/PropertyFieldValidator.php
index 8ac08f0..2e08bf5 100644
--- a/src/PropertyFieldValidator.php
+++ b/src/PropertyFieldValidator.php
@@ -6,7 +6,6 @@ namespace Spatie\DataTransferObject;
 
 use ReflectionNamedType;
 use ReflectionProperty;
-use ReflectionType;
 
 class PropertyFieldValidator extends FieldValidator
 {
@@ -18,7 +17,7 @@ class PropertyFieldValidator extends FieldValidator
         $this->isMixed = $this->resolveIsMixed($property);
         $this->isMixedArray = $this->resolveIsMixedArray($property);
         $this->allowedTypes = $this->resolveAllowedTypes($property);
-        $this->allowedArrayTypes = [];
+        $this->allowedArrayTypes = $this->resolveAllowedArrayTypes($property);
         $this->allowedArrayKeyTypes = [];
     }
 
@@ -59,22 +58,20 @@ class PropertyFieldValidator extends FieldValidator
     private function resolveAllowedTypes(ReflectionProperty $property): array
     {
         // We cast to array to support future union types in PHP 8
-        $types = [$property->getType()];
+        $types = [$property->getType()
+            ? $property->getType()->getName()
+            : null, ];
 
         return $this->normaliseTypes(...$types);
     }
 
-    private function normaliseTypes(?ReflectionType ...$types): array
+    private function resolveAllowedArrayTypes(ReflectionProperty $property): array
     {
-        return array_filter(array_map(
-            function (?ReflectionType $type) {
-                if ($type instanceof ReflectionNamedType) {
-                    $type = $type->getName();
-                }
-
-                return self::$typeMapping[$type] ?? $type;
-            },
-            $types
-        ));
+        // We cast to array to support future union types in PHP 8
+        $types = $property->getType()
+            ? $this->resolveAllowedArrayTypesFromCollection($property->getType()->getName())
+            : [];
+
+        return $this->normaliseTypes(...$types);
     }
 }
diff --git a/src/ValueCaster.php b/src/ValueCaster.php
index 0b624ab..d007d92 100644
--- a/src/ValueCaster.php
+++ b/src/ValueCaster.php
@@ -6,9 +6,14 @@ class ValueCaster
 {
     public function cast($value, FieldValidator $validator)
     {
-        return $this->shouldBeCastToCollection($value)
-            ? $this->castCollection($value, $validator->allowedArrayTypes)
-            : $this->castValue($value, $validator->allowedTypes);
+        if (! $this->shouldBeCastToCollection($value)) {
+            return $this->castValue($value, $validator->allowedTypes);
+        }
+
+        $values = $this->castCollection($value, $validator->allowedArrayTypes);
+        $collectionType = $this->collectionType($validator->allowedTypes);
+
+        return $collectionType ? new $collectionType($values) : $values;
     }
 
     public function castValue($value, array $allowedTypes)
@@ -59,6 +64,17 @@ class ValueCaster
         return $casts;
     }
 
+    public function collectionType(array $types): string
+    {
+        foreach ($types as $type) {
+            if (is_subclass_of($type, DataTransferObjectCollection::class)) {
+                return $type;
+            }
+        }
+
+        return false;
+    }
+
     public function shouldBeCastToCollection(array $values): bool
     {
         if (empty($values)) {


Test patch:
diff --git a/tests/NestedDataTransferObjectCollectionTest.php b/tests/NestedDataTransferObjectCollectionTest.php
new file mode 100644
index 0000000..53c4c15
--- /dev/null
+++ b/tests/NestedDataTransferObjectCollectionTest.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Spatie\DataTransferObject\Tests;
+
+use Spatie\DataTransferObject\DataTransferObject;
+use Spatie\DataTransferObject\DataTransferObjectCollection;
+use Spatie\DataTransferObject\DataTransferObjectError;
+
+class NestedDataTransferObjectCollectionTest extends TestCase
+{
+    /** @test */
+    public function with_typed_properties()
+    {
+        $a = new DtoParentPropertyType([
+            'children' => [
+                ['name' => 'test'],
+                ['name' => 'test2'],
+                ['name' => 'test3'],
+            ],
+        ]);
+
+        $this->assertEquals('test', $a->children[0]->name);
+        $this->assertEquals('test2', $a->children[1]->name);
+        $this->assertEquals('test3', $a->children[2]->name);
+    }
+
+    /** @test */
+    public function with_parent_docblock_property()
+    {
+        $a = new DtoParentPropertyTypeUsingDocblock([
+            'children' => [
+                ['name' => 'test'],
+                ['name' => 'test2'],
+                ['name' => 'test3'],
+            ],
+        ]);
+
+        $this->assertEquals('test', $a->children[0]->name);
+        $this->assertEquals('test2', $a->children[1]->name);
+        $this->assertEquals('test3', $a->children[2]->name);
+    }
+
+    /** @test */
+    public function with_collection_docblock()
+    {
+        $a = new DtoParentPropertyTypeWithCollectionDocblock([
+            'children' => [
+                ['name' => 'test'],
+                ['name' => 'test2'],
+                ['name' => 'test3'],
+            ],
+        ]);
+
+        $this->assertEquals('test', $a->children[0]->name);
+        $this->assertEquals('test2', $a->children[1]->name);
+        $this->assertEquals('test3', $a->children[2]->name);
+    }
+
+    /** @test */
+    public function with_collection_no_type()
+    {
+        $this->expectException(DataTransferObjectError::class);
+        $this->expectExceptionMessage('Collection class `Spatie\DataTransferObject\Tests\DtoChildPropertyTypeCollectionWithNoType` has no defined array type.');
+
+        $a = new DtoParentPropertyTypeWithCollectionNoType([
+            'children' => [
+                ['name' => 'test'],
+                ['name' => 'test2'],
+                ['name' => 'test3'],
+            ],
+        ]);
+    }
+}
+
+// Scenario 1
+class DtoParentPropertyType extends DataTransferObject
+{
+    public DtoChildPropertyTypeCollection $children;
+}
+
+class DtoChildPropertyType extends DataTransferObject
+{
+    public string $name;
+}
+
+class DtoChildPropertyTypeCollection extends DataTransferObjectCollection
+{
+    public function current(): DtoChildPropertyType
+    {
+        return parent::current();
+    }
+}
+
+// Scenario 2
+class DtoParentPropertyTypeUsingDocblock extends DataTransferObject
+{
+    /** @var \Spatie\DataTransferObject\Tests\DtoChildPropertyTypeCollection */
+    public $children;
+}
+
+// Scenario 3
+class DtoParentPropertyTypeWithCollectionDocblock extends DataTransferObject
+{
+    public DtoChildPropertyTypeCollectionWithDocblock $children;
+}
+
+/**
+ * @method \Spatie\DataTransferObject\Tests\DtoChildPropertyType current
+ */
+class DtoChildPropertyTypeCollectionWithDocblock extends DataTransferObjectCollection
+{
+}
+
+// Scenario 4
+class DtoParentPropertyTypeWithCollectionNoType extends DataTransferObject
+{
+    public DtoChildPropertyTypeCollectionWithNoType $children;
+}
+
+class DtoChildPropertyTypeCollectionWithNoType extends DataTransferObjectCollection
+{
+}