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

Issue:
Automatic casting of nested DTOs in V3
I've been missing the feature of automatic casting of nested DTO, for example, in the previous version this would work:

```php
<?php

require __DIR__ . '/vendor/autoload.php';

class FooItem extends \Spatie\DataTransferObject\DataTransferObject
{
    public string $name;
    public float $value;
}

class Foo extends \Spatie\DataTransferObject\DataTransferObject
{
    public string $name;
    /** @var FooItem[] */
    public array $items;
}

$data = [
    'name' => 'FooName',
    'items' => [
        [
            'name' => 'First',
            'value' => 42.0,
        ],
        [
            'name' => 'Second',
            'value' => 42.0,
        ],
    ]
];

$foo = new Foo($data);

var_dump($foo->name);
var_dump($foo->items[0]->name);
var_dump($foo->items[0]->value);
var_dump($foo->items[1]->name);
var_dump($foo->items[1]->value);
```

In version 2 each item is casted into a FooItem, but on version 3 that does not happen.

By reading the docs, in order to have a similar result, I would have to create a Caster class each time I need casting nested DTOs. That seems too much verbosity for something that was previously solved with a simple docblock comment.

Besides, the caster logic for this use case seems pretty straightforward, maybe it could be somehow automated. If automatic casting of nested DTO as in version 2 is not possible, creating a custom attribute to make this process easier could be an option, for example:

```php
<?php

require __DIR__ . '/vendor/autoload.php';

class FooItem extends \Spatie\DataTransferObject\DataTransferObject
{
    public string $name;
    public float $value;
}

class Foo extends \Spatie\DataTransferObject\DataTransferObject
{
    public string $name;
    /** @var FooItem[] */
    #[CastNested(FooItem::class)]
    public array $items;
}
```

So, is this feature of automatic casting nested DTO really missing in version 3? Or am I missing something?

If this is something that could be fixed or implemented, I'm willing to do it.


Pull request:
Add custom arguments to the CastWith attribute
Custom arguments can be used to provide metadata and extra context for
the user-defined casters.

This can be used, for example, to create generic array casters.

Fixes #193

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd61bca..10d67e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,10 +2,6 @@
 
 All notable changes to `data-transfer-object` will be documented in this file
 
-## 3.1.1 - 2021-04-26
-
-- Make `DefaultCast` repeatable (#202)
-
 ## 3.1.0 - 2021-04-21
 
 - Add `DataTransferObject::clone(...$args)`
diff --git a/README.md b/README.md
index c5085cb..8aaabe8 100644
--- a/README.md
+++ b/README.md
@@ -364,6 +364,66 @@ class FooCollectionCaster implements Caster
 }
 ```
 
+### Using custom caster arguments
+
+It is also possible to pass custom arguments for your caster, one may use
+this feature to create a generic collection caster:
+
+```php
+class Bar extends DataTransferObject
+{
+    /** @var \Spatie\DataTransferObject\Tests\Foo[] */
+    #[CastWith(GenericArrayCaster::class, targetClass: Foo::class)]
+    public array $collectionOfFoo;
+
+    /** @var \Spatie\DataTransferObject\Tests\Baz[] */
+    #[CastWith(GenericArrayCaster::class, targetClass: Baz::class)]
+    public array $collectionOfBaz;
+}
+```
+
+```php
+class Foo extends DataTransferObject
+{
+    public string $name;
+}
+
+class Baz extends DataTransferObject
+{
+    public string $name;
+}
+```
+
+```php
+class GenericArrayCaster implements Caster
+{
+    public function __construct(
+        private string $type,
+        private array $args
+    ) {
+    }
+
+    public function cast(mixed $value): array
+    {
+        if ($this->type !== 'array') {
+            throw new Exception("Can only cast arrays");
+        }
+
+        if (! isset($this->args['targetClass'])) {
+            throw new Exception("targetClass argument is required");
+        }
+
+        return array_map(
+            fn (array $data) => new $this->args['targetClass'](...$data),
+            $value
+        );
+    }
+}
+```
+
+The first argument passed to the caster constructor is the type of the value being
+casted. The second is an array with all the arguments given to the `CastWith` attribute.
+
 ## Testing
 
 ``` bash
@@ -393,7 +453,7 @@ We publish all received postcards [on our company website](https://spatie.be/en/
 ## External tools
 
 - [json2dto](https://json2dto.atymic.dev): a GUI to convert JSON objects to DTO classes (with nesting support). Also provides a [CLI tool](https://github.com/atymic/json2dto#cli-tool) for local usage.
-- [Data Transfer Object Factory](https://github.com/anteris-dev/data-transfer-object-factory): Intelligently generates a DTO instance using the correct content for your properties based on its name and type.
+- [Data Transfer Object Factory](https://github.com/anteris-dev/data-transfer-object-factory): generates a DTO instance or collection with fake data based on its definition. Supports type casting and doc blocks.
 
 ## Credits
 
diff --git a/src/Attributes/CastWith.php b/src/Attributes/CastWith.php
index dc96aaf..f552291 100644
--- a/src/Attributes/CastWith.php
+++ b/src/Attributes/CastWith.php
@@ -9,11 +9,16 @@ use Spatie\DataTransferObject\Exceptions\InvalidCasterClass;
 #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
 class CastWith
 {
+    public array $args;
+
     public function __construct(
-        public string $casterClass
+        public string $casterClass,
+        mixed ...$args
     ) {
         if (! class_implements($this->casterClass, Caster::class)) {
             throw new InvalidCasterClass($this->casterClass);
         }
+
+        $this->args = $args;
     }
 }
diff --git a/src/Attributes/DefaultCast.php b/src/Attributes/DefaultCast.php
index b6f196f..fe55ad2 100644
--- a/src/Attributes/DefaultCast.php
+++ b/src/Attributes/DefaultCast.php
@@ -9,7 +9,7 @@ use ReflectionProperty;
 use ReflectionUnionType;
 use Spatie\DataTransferObject\Caster;
 
-#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
+#[Attribute(Attribute::TARGET_CLASS)]
 class DefaultCast
 {
     public function __construct(
diff --git a/src/Reflection/DataTransferObjectProperty.php b/src/Reflection/DataTransferObjectProperty.php
index 6b609a2..999088d 100644
--- a/src/Reflection/DataTransferObjectProperty.php
+++ b/src/Reflection/DataTransferObjectProperty.php
@@ -83,7 +83,8 @@ class DataTransferObjectProperty
         $attribute = $attributes[0]->newInstance();
 
         return new $attribute->casterClass(
-            $this->reflectionProperty->getType()?->getName()
+            $this->reflectionProperty->getType()?->getName(),
+            $attribute->args
         );
     }
 


Test patch:
diff --git a/tests/CustomCasterArgumentsTest.php b/tests/CustomCasterArgumentsTest.php
new file mode 100644
index 0000000..bedb959
--- /dev/null
+++ b/tests/CustomCasterArgumentsTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Spatie\DataTransferObject\Tests;
+
+use Exception;
+use Spatie\DataTransferObject\Attributes\CastWith;
+use Spatie\DataTransferObject\Caster;
+use Spatie\DataTransferObject\DataTransferObject;
+
+class CustomCasterArgumentsTest extends TestCase
+{
+    /** @test */
+    public function test_generic_collection_caster()
+    {
+        $bar = new Bar(
+            [
+                'collectionOfFoo' => [
+                    ['name' => 'a'],
+                    ['name' => 'b'],
+                    ['name' => 'c'],
+                ],
+                'collectionOfBaz' => [
+                    ['name' => 'a'],
+                    ['name' => 'b'],
+                    ['name' => 'c'],
+                ],
+            ]
+        );
+
+        $this->assertCount(3, $bar->collectionOfFoo);
+        $this->assertInstanceOf(Foo::class, $bar->collectionOfFoo[0]);
+
+        $this->assertInstanceOf(Baz::class, $bar->collectionOfBaz[0]);
+        $this->assertCount(3, $bar->collectionOfBaz);
+    }
+}
+
+class Bar extends DataTransferObject
+{
+    /** @var \Spatie\DataTransferObject\Tests\Foo[] */
+    #[CastWith(GenericArrayCaster::class, targetClass: Foo::class)]
+    public array $collectionOfFoo;
+
+    /** @var \Spatie\DataTransferObject\Tests\Baz[] */
+    #[CastWith(GenericArrayCaster::class, targetClass: Baz::class)]
+    public array $collectionOfBaz;
+}
+
+class Foo extends DataTransferObject
+{
+    public string $name;
+}
+
+class Baz extends DataTransferObject
+{
+    public string $name;
+}
+
+class GenericArrayCaster implements Caster
+{
+    public function __construct(
+        private string $type,
+        private array $args
+    ) {
+    }
+
+    public function cast(mixed $value): array
+    {
+        if ($this->type !== 'array') {
+            throw new Exception("Can only cast arrays");
+        }
+
+        if (! isset($this->args['targetClass'])) {
+            throw new Exception("targetClass argument is required");
+        }
+
+        return array_map(
+            fn (array $data) => new $this->args['targetClass'](...$data),
+            $value
+        );
+    }
+}