From 477b6af2f909363e34069d10e3f9a17e3ca3c89d Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 5 Jun 2026 11:03:28 +0200 Subject: [PATCH 1/2] fix(laravel): detect enum casts in eloquent property metadata factory Map Laravel enum casts (BackedEnum and UnitEnum) to Type::enum() instead of falling back to Type::string(). Resolves missing enum schema and normalization downstream (JsonSchema, OpenAPI) when models declare casts via $casts or the casts() method. Fixes #8138 --- .../EloquentPropertyMetadataFactory.php | 17 ++- .../EloquentPropertyMetadataFactoryTest.php | 111 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/Laravel/Tests/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactoryTest.php diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index 5703ca5a673..0fcfa13776c 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -88,7 +88,7 @@ public function create(string $resourceClass, string $property, array $options = 'collection', 'encrypted:collection' => Type::collection(Type::object(Collection::class)), 'encrypted:array' => Type::builtin(TypeIdentifier::ARRAY), 'encrypted:object' => Type::object(), - default => \in_array($builtinType, TypeIdentifier::values(), true) ? Type::builtin($builtinType) : Type::string(), + default => $this->resolveDefaultType($builtinType), }; if ($p['nullable']) { @@ -127,4 +127,19 @@ public function create(string $resourceClass, string $property, array $options = return $propertyMetadata; } + + private function resolveDefaultType(string $builtinType): Type + { + if (\in_array($builtinType, TypeIdentifier::values(), true)) { + return Type::builtin($builtinType); + } + + // Laravel allows passing parameters to class casts via "Class:param" syntax (e.g. AsEnumCollection). + $castClass = explode(':', $builtinType, 2)[0]; + if (enum_exists($castClass)) { + return Type::enum($castClass); + } + + return Type::string(); + } } diff --git a/src/Laravel/Tests/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactoryTest.php b/src/Laravel/Tests/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactoryTest.php new file mode 100644 index 00000000000..ff5723dd9cd --- /dev/null +++ b/src/Laravel/Tests/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactoryTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Eloquent\Metadata\Factory\Property; + +use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory; +use ApiPlatform\Laravel\Eloquent\Metadata\ModelMetadata; +use ApiPlatform\Laravel\workbench\app\Enums\BookStatus; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\EnumType; + +enum CastEnumIntStatus: int +{ + case ACTIVE = 1; + case INACTIVE = 0; +} + +enum CastEnumUnitStatus +{ + case ACTIVE; + case INACTIVE; +} + +class CastEnumStringStatusModel extends Model +{ + protected $table = 'books'; + + protected function casts(): array + { + return [ + 'status' => BookStatus::class, + ]; + } +} + +class CastEnumIntStatusModel extends Model +{ + protected $table = 'books'; + + protected function casts(): array + { + return [ + 'status' => CastEnumIntStatus::class, + ]; + } +} + +class CastEnumUnitStatusModel extends Model +{ + protected $table = 'books'; + + protected function casts(): array + { + return [ + 'status' => CastEnumUnitStatus::class, + ]; + } +} + +/** + * @see https://github.com/api-platform/core/issues/8138 + */ +final class EloquentPropertyMetadataFactoryTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + public function testStringBackedEnumCastIsMappedToEnumType(): void + { + $factory = new EloquentPropertyMetadataFactory(new ModelMetadata()); + $metadata = $factory->create(CastEnumStringStatusModel::class, 'status'); + + $type = $metadata->getNativeType(); + $this->assertInstanceOf(BackedEnumType::class, $type); + $this->assertSame(BookStatus::class, $type->getClassName()); + } + + public function testIntBackedEnumCastIsMappedToEnumType(): void + { + $factory = new EloquentPropertyMetadataFactory(new ModelMetadata()); + $metadata = $factory->create(CastEnumIntStatusModel::class, 'status'); + + $type = $metadata->getNativeType(); + $this->assertInstanceOf(BackedEnumType::class, $type); + $this->assertSame(CastEnumIntStatus::class, $type->getClassName()); + } + + public function testUnitEnumCastIsMappedToEnumType(): void + { + $factory = new EloquentPropertyMetadataFactory(new ModelMetadata()); + $metadata = $factory->create(CastEnumUnitStatusModel::class, 'status'); + + $type = $metadata->getNativeType(); + $this->assertInstanceOf(EnumType::class, $type); + $this->assertSame(CastEnumUnitStatus::class, $type->getClassName()); + } +} From db7cbdebb0f845935302530a7c214c530df2e816 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 5 Jun 2026 11:47:30 +0200 Subject: [PATCH 2/2] fix(laravel): skip enum classes in eloquent resource collection factory PHP's ReflectionClass::newInstanceWithoutConstructor() throws \Error (not \ReflectionException) for enums. When the GraphQl TypeConverter resolves an enum property (e.g. an Eloquent cast to BackedEnum) it walks the resource metadata factory chain, hitting EloquentResourceCollectionMetadataFactory and crashing. Guard with $refl->isEnum() before newInstanceWithoutConstructor() in both EloquentResourceCollectionMetadataFactory and the symmetric EloquentAttributePropertyMetadataFactory, returning the decorated chain's result for the enum class. Adds a unit test that exercises the path with workbench BookStatus. Refs #8138. --- ...oquentAttributePropertyMetadataFactory.php | 7 ++- ...quentResourceCollectionMetadataFactory.php | 5 ++- ...tResourceCollectionMetadataFactoryTest.php | 43 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 src/Laravel/Tests/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactoryTest.php diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentAttributePropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentAttributePropertyMetadataFactory.php index 51ddb890dd9..504dcab49f6 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentAttributePropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentAttributePropertyMetadataFactory.php @@ -37,9 +37,12 @@ public function create(string $resourceClass, string $property, array $options = } $refl = new \ReflectionClass($resourceClass); - $model = $refl->newInstanceWithoutConstructor(); - $propertyMetadata = $this->decorated?->create($resourceClass, $property, $options); + if ($refl->isEnum() || !is_a($resourceClass, Model::class, true)) { + return $propertyMetadata ?? $this->throwNotFound($resourceClass, $property); + } + + $model = $refl->newInstanceWithoutConstructor(); if (!$model instanceof Model) { return $propertyMetadata ?? $this->throwNotFound($resourceClass, $property); } diff --git a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php index 97ef6dae952..706070d288c 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactory.php @@ -71,9 +71,12 @@ public function create(string $resourceClass): ResourceMetadataCollection try { $refl = new \ReflectionClass($resourceClass); + if ($refl->isEnum()) { + return $resourceMetadataCollection; + } $model = $refl->newInstanceWithoutConstructor(); } catch (\ReflectionException) { - return $this->decorated->create($resourceClass); + return $resourceMetadataCollection; } $isModel = $model instanceof Model; diff --git a/src/Laravel/Tests/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactoryTest.php b/src/Laravel/Tests/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactoryTest.php new file mode 100644 index 00000000000..43ea4716d18 --- /dev/null +++ b/src/Laravel/Tests/Eloquent/Metadata/Factory/Resource/EloquentResourceCollectionMetadataFactoryTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Eloquent\Metadata\Factory\Resource; + +use ApiPlatform\Laravel\Eloquent\Metadata\Factory\Resource\EloquentResourceCollectionMetadataFactory; +use ApiPlatform\Laravel\workbench\app\Enums\BookStatus; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +/** + * @see https://github.com/api-platform/core/issues/8138 + */ +final class EloquentResourceCollectionMetadataFactoryTest extends TestCase +{ + use WithWorkbench; + + public function testEnumClassIsNotInstantiated(): void + { + $decorated = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $expected = new ResourceMetadataCollection(BookStatus::class); + $decorated->expects($this->once()) + ->method('create') + ->with(BookStatus::class) + ->willReturn($expected); + + $factory = new EloquentResourceCollectionMetadataFactory($decorated); + + $this->assertSame($expected, $factory->create(BookStatus::class)); + } +}