From ca08e000006a9902fe0a39c540ed2852ce5844e1 Mon Sep 17 00:00:00 2001 From: Bentley O'Kane-Chase Date: Wed, 2 Jul 2025 17:38:55 +1000 Subject: [PATCH 1/5] Add support for custom validation messages and attributes when using property morphable --- ...alidationMessagesAndAttributesResolver.php | 25 +++++- tests/ValidationTest.php | 77 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index 69d8919f9..2770ab03b 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -2,6 +2,7 @@ namespace Spatie\LaravelData\Resolvers; +use Illuminate\Support\Arr; use Spatie\LaravelData\Support\DataConfig; use Spatie\LaravelData\Support\Validation\ValidationPath; @@ -9,6 +10,7 @@ class DataValidationMessagesAndAttributesResolver { public function __construct( protected DataConfig $dataConfig, + protected DataMorphClassResolver $dataMorphClassResolver, ) { } @@ -20,6 +22,21 @@ public function execute( ): array { $dataClass = $this->dataConfig->getDataClass($class); + if ($dataClass->isAbstract && $dataClass->propertyMorphable) { + $payload = $path->isRoot() + ? $fullPayload + : Arr::get($fullPayload, $path->get(), []); + + $morphedClass = $this->dataMorphClassResolver->execute( + $dataClass, + [$payload], + ); + + $dataClass = $morphedClass + ? $this->dataConfig->getDataClass($morphedClass) + : $dataClass; + } + $messages = []; $attributes = []; @@ -72,8 +89,8 @@ public function execute( $messages = array_merge(...$messages); $attributes = array_merge(...$attributes); - if (method_exists($class, 'messages')) { - $messages = collect(app()->call([$class, 'messages'])) + if (method_exists($dataClass->name, 'messages')) { + $messages = collect(app()->call([$dataClass->name, 'messages'])) ->keyBy( fn (mixed $messages, string $key) => ! str_contains($key, '.') && is_string($messages) ? $path->property("*.{$key}")->get() @@ -83,8 +100,8 @@ public function execute( ->all(); } - if (method_exists($class, 'attributes')) { - $attributes = collect(app()->call([$class, 'attributes'])) + if (method_exists($dataClass->name, 'attributes')) { + $attributes = collect(app()->call([$dataClass->name, 'attributes'])) ->keyBy(fn (mixed $messages, string $key) => $path->property($key)->get()) ->merge($attributes) ->all(); diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 0237aca60..8184308c6 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2731,4 +2731,81 @@ public function __construct( 'nestedCollection' => [['variant' => 'a', 'a' => 'foo', 'enum' => 'foo'], ['variant' => 'b', 'b' => 'bar']], ]); }); + + it('can validate property-morphable data with custom messages and attributes', function () { + abstract class TestValidationCustomMessageAbstractPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[PropertyForMorph] + public TestValidationPropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + TestValidationPropertyMorphableEnum::A => TestValidationCustomMessagePropertyMorphableDataA::class, + default => null, + }; + } + + public static function messages() + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + public static function attributes() + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } + } + + class TestValidationCustomMessagePropertyMorphableDataA extends TestValidationCustomMessageAbstractPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) { + parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String]', + ]; + } + } + + DataValidationAsserter::for(TestValidationCustomMessageAbstractPropertyMorphableData::class) + ->assertErrors([ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], [ + 'concrete_integer' => ['Concrete class integer test message.'], + 'concrete_string' => ['The [Concrete String] field is required.'], + 'abstract_integer' => ['Abstract class integer test message.'], + 'abstract_string' => ['The [Abstract String] field is required.'], + ]); + }); }); From 3c42c70d9175cd58ca0dac334a4f9ff316db5161 Mon Sep 17 00:00:00 2001 From: Nolan Phillips Date: Tue, 23 Sep 2025 11:13:43 -0300 Subject: [PATCH 2/5] Support collections of property-morphable data --- ...alidationMessagesAndAttributesResolver.php | 44 +++- tests/ValidationTest.php | 211 +++++++++++++++--- 2 files changed, 213 insertions(+), 42 deletions(-) diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index 2770ab03b..9bad9ce33 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -74,15 +74,43 @@ public function execute( continue; } - $collected = $this->execute( - $dataProperty->type->dataClass, - $fullPayload, - $propertyPath->property('*'), - [...$nestingChain, $dataProperty->type->dataClass], - ); + $collectedDataClass = $this->dataConfig->getDataClass($dataProperty->type->dataClass); + + if ($collectedDataClass->isAbstract && $collectedDataClass->propertyMorphable) { + $items = Arr::get($fullPayload, $propertyPath->get(), []); + foreach ($items as $index => $item) { + $morphedClass = $this->dataMorphClassResolver->execute( + $collectedDataClass, + [$item], + ); + + if (!$morphedClass) { + continue; + } + + $collected = $this->execute( + $morphedClass, + $fullPayload, + $propertyPath->property($index), + [...$nestingChain, $dataProperty->type->dataClass, $morphedClass], + ); + + $messages[] = $collected['messages']; + $attributes[] = $collected['attributes']; + } + + } else { + $collected = $this->execute( + $dataProperty->type->dataClass, + $fullPayload, + $propertyPath->property('*'), + [...$nestingChain, $dataProperty->type->dataClass], + ); + + $messages[] = $collected['messages']; + $attributes[] = $collected['attributes']; - $messages[] = $collected['messages']; - $attributes[] = $collected['attributes']; + } } } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 8184308c6..be8a734ee 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -13,10 +13,6 @@ use Illuminate\Validation\Rules\In as LaravelIn; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; - -use function Pest\Laravel\mock; -use function PHPUnit\Framework\assertFalse; - use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapName; @@ -64,6 +60,8 @@ use Spatie\LaravelData\Tests\Fakes\Support\FakeInjectable; use Spatie\LaravelData\Tests\Fakes\ValidationAttributes\PassThroughCustomValidationAttribute; use Spatie\LaravelData\Tests\TestSupport\DataValidationAsserter; +use function Pest\Laravel\mock; +use function PHPUnit\Framework\assertFalse; it('can validate a string', function () { $dataClass = new class () extends Data { @@ -1315,7 +1313,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.data'); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1333,7 +1331,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0.nested'); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1352,7 +1350,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0.collection.0'); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1375,7 +1373,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0'); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1398,7 +1396,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested'); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1417,7 +1415,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->isRoot(); - if (! $correct) { + if (!$correct) { throw new Exception('Should not end up here'); } @@ -1496,7 +1494,7 @@ public static function rules(): array return [ 'property' => [ new IntegerType(), - new Exists('table', where: fn (Builder $builder) => $builder->is_admin), + new Exists('table', where: fn(Builder $builder) => $builder->is_admin), ], ]; } @@ -1505,7 +1503,7 @@ public static function rules(): array DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ 'integer', - (new LaravelExists('table'))->where(fn (Builder $builder) => $builder->is_admin), + (new LaravelExists('table'))->where(fn(Builder $builder) => $builder->is_admin), ], ]); }); @@ -1518,7 +1516,7 @@ public static function rules(): array $requestMock = mock(Request::class); $requestMock->expects('route')->with('post_id')->andReturns('69'); - $this->app->bind('request', fn () => $requestMock); + $this->app->bind('request', fn() => $requestMock); DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ @@ -1539,7 +1537,7 @@ public static function rules(): array $requestMock->expects('route')->with('post')->andReturns(new DummyModel([ 'id' => 69, ])); - $this->app->bind('request', fn () => $requestMock); + $this->app->bind('request', fn() => $requestMock); DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ @@ -2064,7 +2062,7 @@ public static function redirect(FakeInjectable $injectable): string }); it('can manually set the redirect route', function () { - Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); + Route::get('/never-given-up', fn() => 'Never gonna give you up')->name('never-given-up'); $data = new class () extends Data { public string $name; @@ -2084,7 +2082,7 @@ public static function redirectRoute(): string it('can resolve validation dependencies for redirect route', function () { FakeInjectable::setup('Rick Astley'); - Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); + Route::get('/never-given-up', fn() => 'Never gonna give you up')->name('never-given-up'); $data = new class () extends Data { public string $name; @@ -2482,7 +2480,7 @@ public function __construct(public string|Optional $property = new Optional()) config()->set('data.validation_strategy', ValidationStrategy::Always->value); - expect(fn () => $dataClass::from(['string' => 'Nowp'])) + expect(fn() => $dataClass::from(['string' => 'Nowp'])) ->toThrow(ValidationException::class); }); @@ -2493,7 +2491,8 @@ class TestValidationWithClassMappedAttribute extends Data public function __construct( #[Required] public readonly int $someProperty, - ) { + ) + { } } @@ -2549,13 +2548,14 @@ class TestDataWithMergedRuleset extends Data { public function __construct( #[Max(10)] - public string $array_rules, + public string $array_rules, #[Max(10)] - public string $string_rules, + public string $string_rules, #[WithoutValidation] - public string $without_validation, + public string $without_validation, public TestNestedDataWithMergedRules $nested - ) { + ) + { } public static function rules(): array @@ -2613,14 +2613,15 @@ enum TestValidationPropertyMorphableEnum: string { case A = 'a'; case B = 'b'; - }; + } abstract class TestValidationAbstractPropertyMorphableData extends Data implements PropertyMorphableData { public function __construct( #[PropertyForMorph] public TestValidationPropertyMorphableEnum $variant, - ) { + ) + { } public static function morph(array $properties): ?string @@ -2697,9 +2698,12 @@ class TestValidationNestedPropertyMorphableData extends Data public function __construct( /** @var TestValidationAbstractPropertyMorphableData[] */ public ?DataCollection $nestedCollection, - ) { + ) + { } - }; + } + + ; DataValidationAsserter::for(TestValidationNestedPropertyMorphableData::class) ->assertErrors([ @@ -2739,9 +2743,10 @@ public function __construct( #[PropertyForMorph] public TestValidationPropertyMorphableEnum $variant, #[Max(1)] - public int $abstract_integer, - public string $abstract_string, - ) { + public int $abstract_integer, + public string $abstract_string, + ) + { } public static function morph(array $properties): ?string @@ -2770,12 +2775,13 @@ public static function attributes() class TestValidationCustomMessagePropertyMorphableDataA extends TestValidationCustomMessageAbstractPropertyMorphableData { public function __construct( - int $abstract_integer, - string $abstract_string, + int $abstract_integer, + string $abstract_string, #[Max(1)] - public int $concrete_integer, + public int $concrete_integer, public string $concrete_string, - ) { + ) + { parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); } @@ -2796,7 +2802,7 @@ public static function attributes() } } - DataValidationAsserter::for(TestValidationCustomMessageAbstractPropertyMorphableData::class) + DataValidationAsserter::for(TestValidationCustomMessagePropertyMorphableDataA::class) ->assertErrors([ 'variant' => 'a', 'abstract_integer' => 2, @@ -2808,4 +2814,141 @@ public static function attributes() 'abstract_string' => ['The [Abstract String] field is required.'], ]); }); + + it('can validate collections of property-morphable data with custom messages and attributes', function () { + class TestCustomMessagesInCollectionOfPropertyMorphableData extends Data + { + public function __construct( + + /** + * @var array + */ + public array $items, + ) + { + } + } + + abstract class TestCustomMessageOfPropertyMorphableData extends Data implements PropertyMorphableData + { + public function __construct( + #[PropertyForMorph] + public TestValidationPropertyMorphableEnum $variant, + #[Max(1)] + public int $abstract_integer, + public string $abstract_string, + ) + { + } + + public static function morph(array $properties): ?string + { + return match ($properties['variant']) { + TestValidationPropertyMorphableEnum::A => TestCustomMessagePropertyMorphableDataA::class, + TestValidationPropertyMorphableEnum::B => TestCustomMessagePropertyMorphableDataB::class, + default => null, + }; + } + + public static function messages() + { + return [ + 'abstract_integer.max' => 'Abstract class integer test message.', + ]; + } + + public static function attributes() + { + return [ + 'abstract_string' => '[Abstract String]', + ]; + } + } + + class TestCustomMessagePropertyMorphableDataA extends TestCustomMessageOfPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) + { + parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'concrete_integer.max' => 'Concrete class A integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String A]', + ]; + } + } + + class TestCustomMessagePropertyMorphableDataB extends TestCustomMessageOfPropertyMorphableData + { + public function __construct( + int $abstract_integer, + string $abstract_string, + #[Max(1)] + public int $concrete_integer, + public string $concrete_string, + ) + { + parent::__construct(TestValidationPropertyMorphableEnum::B, $abstract_integer, $abstract_string); + } + + public static function messages() + { + return [ + ...parent::messages(), + 'abstract_integer.max' => 'Concrete Class B override of Abstract class integer test message.', + 'concrete_integer.max' => 'Concrete class B integer test message.', + ]; + } + + public static function attributes() + { + return [ + ...parent::attributes(), + 'concrete_string' => '[Concrete String B]', + ]; + } + } + + + DataValidationAsserter::for(TestCustomMessagesInCollectionOfPropertyMorphableData::class) + ->assertErrors([ + 'items' => [ + [ + 'variant' => 'a', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + [ + 'variant' => 'b', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], + ]], [ + 'items.0.concrete_integer' => ['Concrete class A integer test message.'], + 'items.0.concrete_string' => ['The [Concrete String A] field is required.'], + 'items.0.abstract_integer' => ['Abstract class integer test message.'], + 'items.0.abstract_string' => ['The [Abstract String] field is required.'], + 'items.1.concrete_integer' => ['Concrete class B integer test message.'], + 'items.1.concrete_string' => ['The [Concrete String B] field is required.'], + 'items.1.abstract_integer' => ['Concrete Class B override of Abstract class integer test message.'], + 'items.1.abstract_string' => ['The [Abstract String] field is required.'], + ]); + }); }); From 0b883cdf6b4af5a5f614236b2439af1852b78146 Mon Sep 17 00:00:00 2001 From: Nolan Phillips Date: Tue, 23 Sep 2025 11:15:03 -0300 Subject: [PATCH 3/5] format --- ...alidationMessagesAndAttributesResolver.php | 2 +- tests/ValidationTest.php | 62 ++++++++----------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index 9bad9ce33..e9a136fb6 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -84,7 +84,7 @@ public function execute( [$item], ); - if (!$morphedClass) { + if (! $morphedClass) { continue; } diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index be8a734ee..482a54d5b 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -13,6 +13,10 @@ use Illuminate\Validation\Rules\In as LaravelIn; use Illuminate\Validation\ValidationException; use Illuminate\Validation\Validator; + +use function Pest\Laravel\mock; +use function PHPUnit\Framework\assertFalse; + use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\MapInputName; use Spatie\LaravelData\Attributes\MapName; @@ -60,8 +64,6 @@ use Spatie\LaravelData\Tests\Fakes\Support\FakeInjectable; use Spatie\LaravelData\Tests\Fakes\ValidationAttributes\PassThroughCustomValidationAttribute; use Spatie\LaravelData\Tests\TestSupport\DataValidationAsserter; -use function Pest\Laravel\mock; -use function PHPUnit\Framework\assertFalse; it('can validate a string', function () { $dataClass = new class () extends Data { @@ -1313,7 +1315,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.data'); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1331,7 +1333,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0.nested'); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1350,7 +1352,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0.collection.0'); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1373,7 +1375,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested.collection.0'); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1396,7 +1398,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->equals('nested'); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1415,7 +1417,7 @@ public static function rules(ValidationContext $context): array && $context->fullPayload['property'] === 'Root' && $context->path->isRoot(); - if (!$correct) { + if (! $correct) { throw new Exception('Should not end up here'); } @@ -1494,7 +1496,7 @@ public static function rules(): array return [ 'property' => [ new IntegerType(), - new Exists('table', where: fn(Builder $builder) => $builder->is_admin), + new Exists('table', where: fn (Builder $builder) => $builder->is_admin), ], ]; } @@ -1503,7 +1505,7 @@ public static function rules(): array DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ 'integer', - (new LaravelExists('table'))->where(fn(Builder $builder) => $builder->is_admin), + (new LaravelExists('table'))->where(fn (Builder $builder) => $builder->is_admin), ], ]); }); @@ -1516,7 +1518,7 @@ public static function rules(): array $requestMock = mock(Request::class); $requestMock->expects('route')->with('post_id')->andReturns('69'); - $this->app->bind('request', fn() => $requestMock); + $this->app->bind('request', fn () => $requestMock); DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ @@ -1537,7 +1539,7 @@ public static function rules(): array $requestMock->expects('route')->with('post')->andReturns(new DummyModel([ 'id' => 69, ])); - $this->app->bind('request', fn() => $requestMock); + $this->app->bind('request', fn () => $requestMock); DataValidationAsserter::for($dataClass)->assertRules([ 'property' => [ @@ -2062,7 +2064,7 @@ public static function redirect(FakeInjectable $injectable): string }); it('can manually set the redirect route', function () { - Route::get('/never-given-up', fn() => 'Never gonna give you up')->name('never-given-up'); + Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); $data = new class () extends Data { public string $name; @@ -2082,7 +2084,7 @@ public static function redirectRoute(): string it('can resolve validation dependencies for redirect route', function () { FakeInjectable::setup('Rick Astley'); - Route::get('/never-given-up', fn() => 'Never gonna give you up')->name('never-given-up'); + Route::get('/never-given-up', fn () => 'Never gonna give you up')->name('never-given-up'); $data = new class () extends Data { public string $name; @@ -2480,7 +2482,7 @@ public function __construct(public string|Optional $property = new Optional()) config()->set('data.validation_strategy', ValidationStrategy::Always->value); - expect(fn() => $dataClass::from(['string' => 'Nowp'])) + expect(fn () => $dataClass::from(['string' => 'Nowp'])) ->toThrow(ValidationException::class); }); @@ -2491,8 +2493,7 @@ class TestValidationWithClassMappedAttribute extends Data public function __construct( #[Required] public readonly int $someProperty, - ) - { + ) { } } @@ -2554,8 +2555,7 @@ public function __construct( #[WithoutValidation] public string $without_validation, public TestNestedDataWithMergedRules $nested - ) - { + ) { } public static function rules(): array @@ -2620,8 +2620,7 @@ abstract class TestValidationAbstractPropertyMorphableData extends Data implemen public function __construct( #[PropertyForMorph] public TestValidationPropertyMorphableEnum $variant, - ) - { + ) { } public static function morph(array $properties): ?string @@ -2698,8 +2697,7 @@ class TestValidationNestedPropertyMorphableData extends Data public function __construct( /** @var TestValidationAbstractPropertyMorphableData[] */ public ?DataCollection $nestedCollection, - ) - { + ) { } } @@ -2745,8 +2743,7 @@ public function __construct( #[Max(1)] public int $abstract_integer, public string $abstract_string, - ) - { + ) { } public static function morph(array $properties): ?string @@ -2780,8 +2777,7 @@ public function __construct( #[Max(1)] public int $concrete_integer, public string $concrete_string, - ) - { + ) { parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); } @@ -2824,8 +2820,7 @@ public function __construct( * @var array */ public array $items, - ) - { + ) { } } @@ -2837,8 +2832,7 @@ public function __construct( #[Max(1)] public int $abstract_integer, public string $abstract_string, - ) - { + ) { } public static function morph(array $properties): ?string @@ -2873,8 +2867,7 @@ public function __construct( #[Max(1)] public int $concrete_integer, public string $concrete_string, - ) - { + ) { parent::__construct(TestValidationPropertyMorphableEnum::A, $abstract_integer, $abstract_string); } @@ -2903,8 +2896,7 @@ public function __construct( #[Max(1)] public int $concrete_integer, public string $concrete_string, - ) - { + ) { parent::__construct(TestValidationPropertyMorphableEnum::B, $abstract_integer, $abstract_string); } From 8a02258bc853095b8e9cf5ffd3560c6cd43d30d7 Mon Sep 17 00:00:00 2001 From: Nolan Phillips Date: Wed, 24 Sep 2025 08:20:31 -0300 Subject: [PATCH 4/5] Fallback to the base class when the morphedClass cannot be determined for a collection item --- ...alidationMessagesAndAttributesResolver.php | 11 ++++++----- tests/ValidationTest.php | 19 ++++++++++++++++--- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php index e9a136fb6..00113d442 100644 --- a/src/Resolvers/DataValidationMessagesAndAttributesResolver.php +++ b/src/Resolvers/DataValidationMessagesAndAttributesResolver.php @@ -9,16 +9,16 @@ class DataValidationMessagesAndAttributesResolver { public function __construct( - protected DataConfig $dataConfig, + protected DataConfig $dataConfig, protected DataMorphClassResolver $dataMorphClassResolver, ) { } public function execute( - string $class, - array $fullPayload, + string $class, + array $fullPayload, ValidationPath $path, - array $nestingChain = [], + array $nestingChain = [], ): array { $dataClass = $this->dataConfig->getDataClass($class); @@ -78,6 +78,7 @@ public function execute( if ($collectedDataClass->isAbstract && $collectedDataClass->propertyMorphable) { $items = Arr::get($fullPayload, $propertyPath->get(), []); + foreach ($items as $index => $item) { $morphedClass = $this->dataMorphClassResolver->execute( $collectedDataClass, @@ -85,7 +86,7 @@ public function execute( ); if (! $morphedClass) { - continue; + $morphedClass = $dataProperty->type->dataClass; } $collected = $this->execute( diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 482a54d5b..ab80cfab9 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2927,6 +2927,11 @@ public static function attributes() 'abstract_integer' => 2, 'concrete_integer' => 2, ], + [ + 'variant' => 'invalid', + 'abstract_integer' => 2, + 'concrete_integer' => 2, + ], [ 'variant' => 'b', 'abstract_integer' => 2, @@ -2937,10 +2942,18 @@ public static function attributes() 'items.0.concrete_string' => ['The [Concrete String A] field is required.'], 'items.0.abstract_integer' => ['Abstract class integer test message.'], 'items.0.abstract_string' => ['The [Abstract String] field is required.'], - 'items.1.concrete_integer' => ['Concrete class B integer test message.'], - 'items.1.concrete_string' => ['The [Concrete String B] field is required.'], - 'items.1.abstract_integer' => ['Concrete Class B override of Abstract class integer test message.'], + + 'items.1.variant' => [ + 'The selected items.1.variant is invalid.', + 'The selected items.1.variant is invalid for morph.', + ], + 'items.1.abstract_integer' => ['Abstract class integer test message.'], 'items.1.abstract_string' => ['The [Abstract String] field is required.'], + + 'items.2.concrete_integer' => ['Concrete class B integer test message.'], + 'items.2.concrete_string' => ['The [Concrete String B] field is required.'], + 'items.2.abstract_integer' => ['Concrete Class B override of Abstract class integer test message.'], + 'items.2.abstract_string' => ['The [Abstract String] field is required.'], ]); }); }); From b259b96ec66c85edf5cc9716c2b8899d980a94d8 Mon Sep 17 00:00:00 2001 From: Nolan Phillips Date: Wed, 24 Sep 2025 09:15:57 -0300 Subject: [PATCH 5/5] formatting: Remove semicolon --- tests/ValidationTest.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index ab80cfab9..5d241a7d7 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -2701,8 +2701,6 @@ public function __construct( } } - ; - DataValidationAsserter::for(TestValidationNestedPropertyMorphableData::class) ->assertErrors([ 'nestedCollection' => [[]],