From 9728d019002899af5626621fb6da92a3103642b1 Mon Sep 17 00:00:00 2001 From: Lorenzo Dal'Aqua Date: Wed, 10 Jun 2026 10:47:56 -0300 Subject: [PATCH 1/4] Fix Attribute import in set-only mutator test fixture Complex::stringWithMutatorAndNoAccessor() declared an Attribute return type without importing Illuminate\Database\Eloquent\Casts\Attribute, so the declaration resolved to the non-existent App\Models\Attribute and hasAttributeMutator() never recognized the method. The test added in #110 passed while silently skipping the code path it was meant to cover. With the import in place, the column actually goes through the accessor/attribute branch and still maps to its column type. --- test/laravel-skeleton/app/Models/Complex.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/laravel-skeleton/app/Models/Complex.php b/test/laravel-skeleton/app/Models/Complex.php index 15376e1..306f4f2 100644 --- a/test/laravel-skeleton/app/Models/Complex.php +++ b/test/laravel-skeleton/app/Models/Complex.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\UpperCast; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; From e816f11f49170609e94214606bd9d02d931845e7 Mon Sep 17 00:00:00 2001 From: Lorenzo Dal'Aqua Date: Wed, 10 Jun 2026 10:49:25 -0300 Subject: [PATCH 2/4] Skip non-case constants when writing enum consts ReflectionClass::getConstants() returns plain class constants alongside enum cases, so an enum that also declares helper constants with non-case values (e.g. a const array of cases) crashed the writer with "Attempt to read property 'name' on array". Keep only the constants that are enum case instances. The NON_ADMIN_ROLES constant on the Roles fixture covers this through every test that renders the enum. --- src/Actions/WriteEnumConst.php | 3 ++- test/laravel-skeleton/app/Enums/Roles.php | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Actions/WriteEnumConst.php b/src/Actions/WriteEnumConst.php index f89be47..f513c49 100644 --- a/src/Actions/WriteEnumConst.php +++ b/src/Actions/WriteEnumConst.php @@ -25,7 +25,8 @@ public function __invoke(ReflectionClass $reflection, string $indent = '', bool $comments = array_map(fn ($match) => trim(str_replace('@property', '', $match)), $matches[0]); } - $cases = collect($reflection->getConstants()); + $cases = collect($reflection->getConstants()) + ->filter(fn ($case) => $case instanceof \UnitEnum); if ($cases->isNotEmpty()) { if ($useEnums) { diff --git a/test/laravel-skeleton/app/Enums/Roles.php b/test/laravel-skeleton/app/Enums/Roles.php index 0f3ccb7..8404d14 100644 --- a/test/laravel-skeleton/app/Enums/Roles.php +++ b/test/laravel-skeleton/app/Enums/Roles.php @@ -15,6 +15,11 @@ enum Roles: string case USER = 'user'; case USERCLASS = User::class; + public const NON_ADMIN_ROLES = [ + self::USER, + self::USERCLASS, + ]; + public static function fromValue(string $value): self { return match ($value) { From e4e8b46a4558c572a482f9afec2ecf7787398069 Mon Sep 17 00:00:00 2001 From: Lorenzo Dal'Aqua Date: Wed, 10 Jun 2026 10:50:18 -0300 Subject: [PATCH 3/4] Fall back to model casts for set-only attribute mutators When an Attribute mutator defines no get callback, reads still go through the cast defined on the model, but the generator fell back straight to the database column type (#110). An attribute with an enum cast and a set-only mutator therefore emitted the column type (string) instead of the enum. Resolve the model's cast first -- enum classes map to their generated const, scalar casts go through the regular mappings -- and only use the column type when no cast matches. --- src/Actions/WriteColumnAttribute.php | 23 ++++++++++++++++++- .../expectations/complex-model-camel-case.ts | 12 ++++++++++ .../expectations/complex-model-pascal-case.ts | 12 ++++++++++ .../expectations/complex-model-with-cast.ts | 12 ++++++++++ test/input/expectations/complex-model.ts | 12 ++++++++++ test/laravel-skeleton/app/Models/Complex.php | 9 ++++++++ ...1_01_000003_create_complex_model_table.php | 1 + 7 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/Actions/WriteColumnAttribute.php b/src/Actions/WriteColumnAttribute.php index 8e49891..eed29eb 100644 --- a/src/Actions/WriteColumnAttribute.php +++ b/src/Actions/WriteColumnAttribute.php @@ -110,7 +110,28 @@ public function __invoke(ReflectionClass $reflectionModel, array $attribute, arr } } } else { - if ($attribute['type'] !== null) { + // No get callback: reads go through the model's cast, + // so fall back to it before the database column type + $cast = $reflectionModel->newInstance()->getCasts()[$attribute['name']] ?? null; + + if (! is_null($cast)) { + if (Str::contains($cast, '\\')) { + $castReflection = new ReflectionClass($cast); + + if ($castReflection->isEnum()) { + $type = $this->getClassName($cast); + $enumRef = $castReflection; + } + } else { + $cleanStr = Str::of($cast)->before(':')->lower()->toString(); + + if (isset($mappings[$cleanStr])) { + $type = $returnType($cleanStr, $mappings); + } + } + } + + if ($type === 'unknown' && $attribute['type'] !== null) { $type = $returnType($attribute['type'], $mappings); } } diff --git a/test/input/expectations/complex-model-camel-case.ts b/test/input/expectations/complex-model-camel-case.ts index c4de839..7325ade 100644 --- a/test/input/expectations/complex-model-camel-case.ts +++ b/test/input/expectations/complex-model-camel-case.ts @@ -26,6 +26,7 @@ export interface Complex { string: string castedUppercaseString: unknown stringWithMutatorAndNoAccessor: string + enumWithMutatorAndNoAccessor: Roles text: string time: string timestamp: string @@ -42,3 +43,14 @@ export interface Complex { // exists complexRelationshipsExists: boolean } + +const Roles = { + /** Can do anything */ + ADMIN: 'admin', + /** Standard readonly */ + USER: 'user', + /** Value that needs string escaping */ + USERCLASS: 'App\\Models\\User', +} as const; + +export type Roles = typeof Roles[keyof typeof Roles] diff --git a/test/input/expectations/complex-model-pascal-case.ts b/test/input/expectations/complex-model-pascal-case.ts index 7c4e5db..207164b 100644 --- a/test/input/expectations/complex-model-pascal-case.ts +++ b/test/input/expectations/complex-model-pascal-case.ts @@ -26,6 +26,7 @@ export interface Complex { String: string CastedUppercaseString: unknown StringWithMutatorAndNoAccessor: string + EnumWithMutatorAndNoAccessor: Roles Text: string Time: string Timestamp: string @@ -42,3 +43,14 @@ export interface Complex { // exists ComplexRelationshipsExists: boolean } + +const Roles = { + /** Can do anything */ + ADMIN: 'admin', + /** Standard readonly */ + USER: 'user', + /** Value that needs string escaping */ + USERCLASS: 'App\\Models\\User', +} as const; + +export type Roles = typeof Roles[keyof typeof Roles] diff --git a/test/input/expectations/complex-model-with-cast.ts b/test/input/expectations/complex-model-with-cast.ts index e17d35c..2889d1a 100644 --- a/test/input/expectations/complex-model-with-cast.ts +++ b/test/input/expectations/complex-model-with-cast.ts @@ -26,6 +26,7 @@ export interface Complex { string: string casted_uppercase_string: string string_with_mutator_and_no_accessor: string + enum_with_mutator_and_no_accessor: Roles text: string time: string timestamp: string @@ -42,3 +43,14 @@ export interface Complex { // exists complex_relationships_exists: boolean } + +const Roles = { + /** Can do anything */ + ADMIN: 'admin', + /** Standard readonly */ + USER: 'user', + /** Value that needs string escaping */ + USERCLASS: 'App\\Models\\User', +} as const; + +export type Roles = typeof Roles[keyof typeof Roles] diff --git a/test/input/expectations/complex-model.ts b/test/input/expectations/complex-model.ts index 364725e..4d1eed6 100644 --- a/test/input/expectations/complex-model.ts +++ b/test/input/expectations/complex-model.ts @@ -26,6 +26,7 @@ export interface Complex { string: string casted_uppercase_string: unknown string_with_mutator_and_no_accessor: string + enum_with_mutator_and_no_accessor: Roles text: string time: string timestamp: string @@ -42,3 +43,14 @@ export interface Complex { // exists complex_relationships_exists: boolean } + +const Roles = { + /** Can do anything */ + ADMIN: 'admin', + /** Standard readonly */ + USER: 'user', + /** Value that needs string escaping */ + USERCLASS: 'App\\Models\\User', +} as const; + +export type Roles = typeof Roles[keyof typeof Roles] diff --git a/test/laravel-skeleton/app/Models/Complex.php b/test/laravel-skeleton/app/Models/Complex.php index 306f4f2..c819348 100644 --- a/test/laravel-skeleton/app/Models/Complex.php +++ b/test/laravel-skeleton/app/Models/Complex.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Casts\UpperCast; +use App\Enums\Roles; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -16,6 +17,7 @@ class Complex extends Model 'jsonb' => 'json', 'year' => 'int', 'casted_uppercase_string' => UpperCast::class, + 'enum_with_mutator_and_no_accessor' => Roles::class, 'immutableDateTime' => 'immutable_date', 'immutableDate' => 'immutable_datetime', 'immutableCustomDateTime' => 'immutable_custom_datetime', @@ -32,4 +34,11 @@ protected function stringWithMutatorAndNoAccessor(): Attribute set: fn (string $value): string => strtolower($value), ); } + + protected function enumWithMutatorAndNoAccessor(): Attribute + { + return Attribute::make( + set: fn (Roles|string $value): string => $value instanceof Roles ? $value->value : $value, + ); + } } diff --git a/test/laravel-skeleton/database/migrations/0001_01_01_000003_create_complex_model_table.php b/test/laravel-skeleton/database/migrations/0001_01_01_000003_create_complex_model_table.php index 30c4fe4..cd5bcc2 100644 --- a/test/laravel-skeleton/database/migrations/0001_01_01_000003_create_complex_model_table.php +++ b/test/laravel-skeleton/database/migrations/0001_01_01_000003_create_complex_model_table.php @@ -38,6 +38,7 @@ public function up(): void $table->string('string'); $table->string('casted_uppercase_string'); $table->string('string_with_mutator_and_no_accessor'); + $table->string('enum_with_mutator_and_no_accessor'); $table->text('text'); $table->time('time'); $table->timestamp('timestamp'); From a0288dee3ae1893d016fe56406dcd3289c943077 Mon Sep 17 00:00:00 2001 From: Lorenzo Dal'Aqua Date: Wed, 10 Jun 2026 11:32:41 -0300 Subject: [PATCH 4/4] Narrow UnitEnum to BackedEnum when iterating enum cases UnitEnum is the base interface for all enums but only BackedEnum has a value property. PHPStan correctly flags the access to $case->value as undefined on UnitEnum. Since the package can only emit meaningful TypeScript values for backed enums, narrowing the filter to BackedEnum also makes the intent explicit. --- src/Actions/WriteEnumConst.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Actions/WriteEnumConst.php b/src/Actions/WriteEnumConst.php index f513c49..f2eacb1 100644 --- a/src/Actions/WriteEnumConst.php +++ b/src/Actions/WriteEnumConst.php @@ -26,7 +26,7 @@ public function __invoke(ReflectionClass $reflection, string $indent = '', bool } $cases = collect($reflection->getConstants()) - ->filter(fn ($case) => $case instanceof \UnitEnum); + ->filter(fn ($case) => $case instanceof \BackedEnum); if ($cases->isNotEmpty()) { if ($useEnums) {