From e008b92c0d2ac023d93c0324642cbae9923df7c2 Mon Sep 17 00:00:00 2001 From: Erik Gaal Date: Tue, 20 Dec 2022 14:43:31 +0100 Subject: [PATCH] feat(refactor): ModelProperty casting (#1333) Fixes https://github.com/nunomaduro/larastan/issues/890 --- extension.neon | 3 + src/Properties/ModelCastHelper.php | 165 +++++++++ src/Properties/ModelPropertyExtension.php | 318 +++++------------- src/Properties/SchemaColumn.php | 34 +- src/Properties/SchemaTable.php | 2 +- tests/Application/app/Casts/Favorites.php | 33 ++ tests/Application/app/Casts/Hash.php | 42 +++ tests/Application/app/User.php | 23 ++ .../app/ValueObjects/Favorites.php | 7 + .../2020_01_30_000000_create_users_table.php | 23 ++ .../Properties/ModelPropertyExtension.php | 11 + tests/Type/data/model-properties.php | 28 +- 12 files changed, 432 insertions(+), 257 deletions(-) create mode 100644 src/Properties/ModelCastHelper.php create mode 100644 tests/Application/app/Casts/Favorites.php create mode 100644 tests/Application/app/Casts/Hash.php create mode 100644 tests/Application/app/ValueObjects/Favorites.php diff --git a/extension.neon b/extension.neon index 6a0f5d7f5..69b72f2e0 100644 --- a/extension.neon +++ b/extension.neon @@ -410,6 +410,9 @@ services: arguments: schemaPaths: %squashedMigrationsPath% + - + class: NunoMaduro\Larastan\Properties\ModelCastHelper + - class: NunoMaduro\Larastan\Rules\ModelProperties\ModelPropertiesRuleHelper diff --git a/src/Properties/ModelCastHelper.php b/src/Properties/ModelCastHelper.php new file mode 100644 index 000000000..74fac3058 --- /dev/null +++ b/src/Properties/ModelCastHelper.php @@ -0,0 +1,165 @@ +parseCast($cast); + + $attributeType = match ($castType) { + 'int', 'integer' => new IntegerType(), + 'real', 'float', 'double' => new FloatType(), + 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), + 'string' => new StringType(), + 'bool', 'boolean' => new BooleanType(), + 'object' => new ObjectType('stdClass'), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType()), + 'collection' => new ObjectType('Illuminate\Support\Collection'), + 'date', 'datetime' => $this->getDateType(), + 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), + 'timestamp' => new IntegerType(), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isSubclassOf(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + if ($castUsingReturn instanceof ObjectType && $castReflection = $castUsingReturn->getClassReflection()) { + $classReflection = $castReflection; + } + } + + if ($classReflection->isSubclassOf(CastsAttributes::class)) { + $methodReflection = $classReflection->getNativeMethod('get'); + + return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + } + + if ($classReflection->isSubclassOf(CastsInboundAttributes::class)) { + return $originalType; + } + + return new MixedType(); + } + + public function getWriteableType(string $cast, Type $originalType): Type + { + $castType = $this->parseCast($cast); + + $attributeType = match ($castType) { + 'int', 'integer' => new IntegerType(), + 'real', 'float', 'double' => new FloatType(), + 'decimal' => TypeCombinator::intersect(new StringType(), new AccessoryNumericStringType()), + 'string' => new StringType(), + 'bool', 'boolean' => TypeCombinator::union(new BooleanType(), new ConstantIntegerType(0), new ConstantIntegerType(1)), + 'object' => new ObjectType('stdClass'), + 'array', 'json' => new ArrayType(new BenevolentUnionType([new IntegerType(), new StringType()]), new MixedType()), + 'collection' => new ObjectType('Illuminate\Support\Collection'), + 'date', 'datetime' => $this->getDateType(), + 'immutable_date', 'immutable_datetime' => new ObjectType('Carbon\CarbonImmutable'), + 'timestamp' => new IntegerType(), + default => null, + }; + + if ($attributeType) { + return $attributeType; + } + + if (! $this->reflectionProvider->hasClass($cast)) { + return $originalType; + } + + $classReflection = $this->reflectionProvider->getClass($cast); + + if ($classReflection->isSubclassOf(Castable::class)) { + $methodReflection = $classReflection->getNativeMethod('castUsing'); + $castUsingReturn = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + if ($castUsingReturn instanceof ObjectType && $castReflection = $castUsingReturn->getClassReflection()) { + $classReflection = $castReflection; + } + } + + if ( + $classReflection->isSubclassOf(CastsAttributes::class) + || $classReflection->isSubclassOf(CastsInboundAttributes::class) + ) { + $methodReflection = $classReflection->getNativeMethod('set'); + $parameters = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getParameters(); + + $valueParameter = Arr::first($parameters, fn (ParameterReflection $parameterReflection) => $parameterReflection->getName() === 'value'); + + return $valueParameter->getType(); + } + + return new MixedType(); + } + + public function getDateType(): Type + { + $dateClass = class_exists(\Illuminate\Support\Facades\Date::class) + ? \Illuminate\Support\Facades\Date::now()::class + : \Illuminate\Support\Carbon::class; + + if ($dateClass === \Illuminate\Support\Carbon::class) { + return TypeCombinator::union(new ObjectType($dateClass), new ObjectType(\Carbon\Carbon::class)); + } + + return new ObjectType($dateClass); + } + + /** + * @param string $cast + * @return string|null + */ + private function parseCast(string $cast): ?string + { + foreach (explode(':', $cast) as $part) { + // If the cast is prefixed with `encrypted:` we need to skip to the next + if ($part === 'encrypted') { + continue; + } + + return $part; + } + + return null; + } +} diff --git a/src/Properties/ModelPropertyExtension.php b/src/Properties/ModelPropertyExtension.php index 678a87bc1..2ebba7046 100644 --- a/src/Properties/ModelPropertyExtension.php +++ b/src/Properties/ModelPropertyExtension.php @@ -4,20 +4,19 @@ namespace NunoMaduro\Larastan\Properties; -use ArrayObject; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; use NunoMaduro\Larastan\Reflection\ReflectionHelper; -use PHPStan\Broker\ClassNotFoundException; use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\PropertiesClassReflectionExtension; use PHPStan\Reflection\PropertyReflection; -use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\ObjectType; +use PHPStan\Type\StringType; +use PHPStan\Type\TypeCombinator; /** * @internal @@ -25,13 +24,14 @@ final class ModelPropertyExtension implements PropertiesClassReflectionExtension { /** @var array */ - private $tables = []; - - /** @var string */ - private $dateClass; - - public function __construct(private TypeStringResolver $stringResolver, private MigrationHelper $migrationHelper, private SquashedMigrationHelper $squashedMigrationHelper, private ReflectionProvider $reflectionProvider) - { + private array $tables = []; + + public function __construct( + private TypeStringResolver $stringResolver, + private MigrationHelper $migrationHelper, + private SquashedMigrationHelper $squashedMigrationHelper, + private ModelCastHelper $modelCastHelper, + ) { } public function hasProperty(ClassReflection $classReflection, string $propertyName): bool @@ -44,7 +44,7 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa return false; } - if ($this->hasAttribute($classReflection, $propertyName)) { + if ($this->hasAccessor($classReflection, $propertyName)) { return false; } @@ -52,271 +52,135 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa return false; } - if (count($this->tables) === 0) { - // First try to create tables from squashed migrations, if there are any - // Then scan the normal migration files for further changes to tables. - $tables = $this->squashedMigrationHelper->initializeTables(); - - $this->tables = $this->migrationHelper->initializeTables($tables); - } - - if ($propertyName === 'id') { - return true; + if (! $this->migrationsLoaded()) { + $this->loadMigrations(); } - $modelName = $classReflection->getNativeReflection()->getName(); - try { - $reflect = $this->reflectionProvider->getClass($modelName); - /** @var Model $modelInstance */ - $modelInstance = $reflect->getNativeReflection()->newInstanceWithoutConstructor(); - - $tableName = $modelInstance->getTable(); - } catch (ClassNotFoundException|\ReflectionException $e) { + $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (\ReflectionException $e) { return false; } + if ($propertyName === $modelInstance->getKeyName()) { + return true; + } + + $tableName = $modelInstance->getTable(); + if (! array_key_exists($tableName, $this->tables)) { return false; } - if (! array_key_exists($propertyName, $this->tables[$tableName]->columns)) { - return false; + return array_key_exists($propertyName, $this->tables[$tableName]->columns); + } + + private function hasAccessor(ClassReflection $classReflection, string $propertyName): bool + { + $propertyNameStudlyCase = Str::studly($propertyName); + + if ($classReflection->hasNativeMethod(sprintf('get%sAttribute', $propertyNameStudlyCase))) { + return true; } - $this->castPropertiesType($modelInstance); + $propertyNameCamelCase = Str::camel($propertyName); - $column = $this->tables[$tableName]->columns[$propertyName]; + if ($classReflection->hasNativeMethod($propertyNameCamelCase)) { + $methodReflection = $classReflection->getNativeMethod($propertyNameCamelCase); + + if ($methodReflection->isPublic() || $methodReflection->isPrivate()) { + return false; + } + + $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); + + return (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes(); + } - [$readableType, $writableType] = $this->getReadableAndWritableTypes($column, $modelInstance); + return false; + } - $column->readableType = $readableType; - $column->writeableType = $writableType; + private function migrationsLoaded(): bool + { + return count($this->tables) > 0; + } - $this->tables[$tableName]->columns[$propertyName] = $column; + private function loadMigrations(): void + { + // First try to create tables from squashed migrations, if there are any + // Then scan the normal migration files for further changes to tables. + $tables = $this->squashedMigrationHelper->initializeTables(); - return true; + $this->tables = $this->migrationHelper->initializeTables($tables); } public function getProperty( ClassReflection $classReflection, string $propertyName ): PropertyReflection { - $modelName = $classReflection->getNativeReflection()->getName(); - try { - $reflect = $this->reflectionProvider->getClass($modelName); - /** @var Model $modelInstance */ - $modelInstance = $reflect->getNativeReflection()->newInstanceWithoutConstructor(); - - $tableName = $modelInstance->getTable(); - } catch (ClassNotFoundException|\ReflectionException $e) { - // `hasProperty` should return false if there was a reflection exception. - // so this should never happen + $modelInstance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor(); + } catch (\ReflectionException $e) { throw new ShouldNotHappenException(); } + $tableName = $modelInstance->getTable(); + if ( - ( - ! array_key_exists($tableName, $this->tables) - || ! array_key_exists($propertyName, $this->tables[$tableName]->columns) - ) - && $propertyName === 'id' + $propertyName === $modelInstance->getKeyName() + && (! array_key_exists($tableName, $this->tables) || ! array_key_exists($propertyName, $this->tables[$tableName]->columns)) ) { return new ModelProperty( $classReflection, $this->stringResolver->resolve($modelInstance->getKeyType()), - $this->stringResolver->resolve($modelInstance->getKeyType()) + $this->stringResolver->resolve($modelInstance->getKeyType()), ); } $column = $this->tables[$tableName]->columns[$propertyName]; - return new ModelProperty( - $classReflection, - $this->stringResolver->resolve($column->readableType), - $this->stringResolver->resolve($column->writeableType) - ); - } - - private function getDateClass(): string - { - if (! $this->dateClass) { - $this->dateClass = class_exists(\Illuminate\Support\Facades\Date::class) - ? '\\'.get_class(\Illuminate\Support\Facades\Date::now()) - : '\Illuminate\Support\Carbon'; - - if ($this->dateClass === '\Illuminate\Support\Carbon') { - $this->dateClass .= '|\Carbon\Carbon'; - } - } - - return $this->dateClass; - } - - /** - * @param Model $modelInstance - * @return string[] - * @phpstan-return array - */ - private function getModelDateColumns(Model $modelInstance): array - { - $dateColumns = $modelInstance->getDates(); + if ($this->hasDate($modelInstance, $propertyName)) { + $readableType = $this->modelCastHelper->getDateType(); + $writeableType = TypeCombinator::union($this->modelCastHelper->getDateType(), new StringType()); + } elseif ($modelInstance->hasCast($propertyName)) { + $cast = $modelInstance->getCasts()[$propertyName]; - if (method_exists($modelInstance, 'getDeletedAtColumn')) { - $dateColumns[] = $modelInstance->getDeletedAtColumn(); - } - - return $dateColumns; - } - - /** - * @param SchemaColumn $column - * @param Model $modelInstance - * @return string[] - * @phpstan-return array - */ - private function getReadableAndWritableTypes(SchemaColumn $column, Model $modelInstance): array - { - $readableType = $column->readableType; - $writableType = $column->writeableType; - - if (in_array($column->name, $this->getModelDateColumns($modelInstance), true)) { - return [$this->getDateClass().($column->nullable ? '|null' : ''), $this->getDateClass().'|string'.($column->nullable ? '|null' : '')]; + $readableType = $this->modelCastHelper->getReadableType( + $cast, + $this->stringResolver->resolve($column->readableType), + ); + $writeableType = $this->modelCastHelper->getWriteableType( + $cast, + $this->stringResolver->resolve($column->writeableType), + ); + } else { + $readableType = $this->stringResolver->resolve($column->readableType); + $writeableType = $this->stringResolver->resolve($column->writeableType); } - switch ($column->readableType) { - case 'string': - case 'int': - case 'float': - $readableType = $writableType = $column->readableType.($column->nullable ? '|null' : ''); - break; - - case 'boolean': - case 'bool': - switch ((string) config('database.default')) { - case 'sqlite': - case 'mysql': - $writableType = '0|1|bool'; - $readableType = 'bool'; - break; - default: - $readableType = $writableType = 'bool'; - break; - } - break; - case 'enum': - case 'set': - if (! $column->options) { - $readableType = $writableType = 'string'; - } else { - $readableType = $writableType = '\''.implode('\'|\'', $column->options).'\''; - } - - break; - - default: - break; + if ($column->nullable) { + $readableType = TypeCombinator::addNull($readableType); + $writeableType = TypeCombinator::addNull($writeableType); } - return [$readableType, $writableType]; - } - - private function castPropertiesType(Model $modelInstance): void - { - $casts = $modelInstance->getCasts(); - foreach ($casts as $name => $type) { - if (! array_key_exists($name, $this->tables[$modelInstance->getTable()]->columns)) { - continue; - } - - // Reduce encrypted castable types - if (in_array($type, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], true)) { - $type = Str::after($type, 'encrypted:'); - } - - // Truncate cast parameters - $type = Str::before($type, ':'); - - switch ($type) { - case 'boolean': - case 'bool': - $realType = 'boolean'; - break; - case 'string': - case 'decimal': - $realType = 'string'; - break; - case 'array': - case 'json': - $realType = 'array'; - break; - case 'object': - $realType = 'object'; - break; - case 'int': - case 'integer': - case 'timestamp': - $realType = 'integer'; - break; - case 'real': - case 'double': - case 'float': - $realType = 'float'; - break; - case 'date': - case 'datetime': - $realType = $this->getDateClass(); - break; - case 'collection': - $realType = '\Illuminate\Support\Collection'; - break; - case 'Illuminate\Database\Eloquent\Casts\AsArrayObject': - $realType = ArrayObject::class; - break; - case 'Illuminate\Database\Eloquent\Casts\AsCollection': - $realType = '\Illuminate\Support\Collection'; - break; - default: - $realType = class_exists($type) ? ('\\'.$type) : 'mixed'; - break; - } - - if ($this->tables[$modelInstance->getTable()]->columns[$name]->nullable) { - $realType .= '|null'; - } - - $this->tables[$modelInstance->getTable()]->columns[$name]->readableType = $realType; - $this->tables[$modelInstance->getTable()]->columns[$name]->writeableType = $realType; - } + return new ModelProperty( + $classReflection, + $readableType, + $writeableType, + ); } - private function hasAttribute(ClassReflection $classReflection, string $propertyName): bool + private function hasDate(Model $modelInstance, string $propertyName): bool { - if ($classReflection->hasNativeMethod('get'.Str::studly($propertyName).'Attribute')) { - return true; - } + $dates = $modelInstance->getDates(); - $camelCase = Str::camel($propertyName); - - if ($classReflection->hasNativeMethod($camelCase)) { - $methodReflection = $classReflection->getNativeMethod($camelCase); - - if ($methodReflection->isPublic() || $methodReflection->isPrivate()) { - return false; - } - - $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); - - if (! (new ObjectType(Attribute::class))->isSuperTypeOf($returnType)->yes()) { - return false; - } - - return true; + // In order to support SoftDeletes + if (method_exists($modelInstance, 'getDeletedAtColumn')) { + $dates[] = $modelInstance->getDeletedAtColumn(); } - return false; + return in_array($propertyName, $dates); } } diff --git a/src/Properties/SchemaColumn.php b/src/Properties/SchemaColumn.php index a09d56755..cfe436a4d 100644 --- a/src/Properties/SchemaColumn.php +++ b/src/Properties/SchemaColumn.php @@ -9,37 +9,15 @@ */ final class SchemaColumn { - /** @var string */ - public $name; + public string $writeableType; - /** @var string */ - public $readableType; - - /** @var string */ - public $writeableType; - - /** @var bool */ - public $nullable; - - /** @var ?array */ - public $options; - - /** - * @param string $name - * @param string $readableType - * @param bool $nullable - * @param string[]|null $options - */ public function __construct( - string $name, - string $readableType, - bool $nullable = false, - ?array $options = null + public string $name, + public string $readableType, + public bool $nullable = false, + /** @var array */ + public ?array $options = null ) { - $this->name = $name; - $this->readableType = $readableType; $this->writeableType = $readableType; - $this->nullable = $nullable; - $this->options = $options; } } diff --git a/src/Properties/SchemaTable.php b/src/Properties/SchemaTable.php index 93d2abfad..91932fe79 100644 --- a/src/Properties/SchemaTable.php +++ b/src/Properties/SchemaTable.php @@ -12,7 +12,7 @@ final class SchemaTable /** @var string */ public $name; - /** @var SchemaColumn[] */ + /** @var array */ public $columns = []; public function __construct(string $name) diff --git a/tests/Application/app/Casts/Favorites.php b/tests/Application/app/Casts/Favorites.php new file mode 100644 index 000000000..9fe990eb5 --- /dev/null +++ b/tests/Application/app/Casts/Favorites.php @@ -0,0 +1,33 @@ +algorithm = $algorithm; + } + + /** + * Prepare the given value for storage. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param string $key + * @param string $value + * @param array $attributes + * @return string + */ + public function set($model, string $key, $value, $attributes) + { + return is_null($this->algorithm) + ? bcrypt($value) + : hash($this->algorithm, $value); + } +} diff --git a/tests/Application/app/User.php b/tests/Application/app/User.php index f4fcd25ed..cbedd5a1f 100644 --- a/tests/Application/app/User.php +++ b/tests/Application/app/User.php @@ -2,6 +2,8 @@ namespace App; +use App\Casts\Favorites; +use App\Casts\Hash; use function get_class; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\AsArrayObject; @@ -50,6 +52,27 @@ class User extends Authenticatable 'floatButRoundedDecimalString' => 'decimal:1', 'options' => AsArrayObject::class, 'properties' => AsCollection::class, + 'favorites' => Favorites::class, + 'secret' => Hash::class.':sha256', + + 'int' => 'int', + 'integer' => 'integer', + 'real' => 'real', + 'float' => 'float', + 'double' => 'double', + 'decimal' => 'decimal', + 'string' => 'string', + 'bool' => 'bool', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + 'json' => 'json', + 'collection' => 'collection', + 'date' => 'date', + 'datetime' => 'datetime', + 'immutable_date' => 'immutable_date', + 'immutable_datetime' => 'immutable_datetime', + 'timestamp' => 'timestamp', ]; /** diff --git a/tests/Application/app/ValueObjects/Favorites.php b/tests/Application/app/ValueObjects/Favorites.php new file mode 100644 index 000000000..be8dd880d --- /dev/null +++ b/tests/Application/app/ValueObjects/Favorites.php @@ -0,0 +1,7 @@ +json('meta'); $table->json('options'); $table->json('properties'); + $table->json('favorites'); + $table->string('secret'); $table->boolean('blocked'); $table->unknownColumnType('unknown_column'); $table->rememberToken(); + + // Testing property casts + $table->integer('int'); + $table->integer('integer'); + $table->float('real'); + $table->float('float'); + $table->double('double'); + $table->decimal('decimal'); + $table->string('string'); + $table->boolean('bool'); + $table->boolean('boolean'); + $table->json('object'); + $table->json('array'); + $table->json('json'); + $table->json('collection'); + $table->date('date'); + $table->dateTime('datetime'); + $table->date('immutable_date'); + $table->dateTime('immutable_datetime'); + $table->timestamp('timestamp'); + $table->timestamps(); $table->softDeletes(); }); diff --git a/tests/Features/Properties/ModelPropertyExtension.php b/tests/Features/Properties/ModelPropertyExtension.php index cd7772b47..bbe9c4696 100644 --- a/tests/Features/Properties/ModelPropertyExtension.php +++ b/tests/Features/Properties/ModelPropertyExtension.php @@ -12,6 +12,7 @@ use App\Team; use App\Thread; use App\User; +use App\ValueObjects\Favorites; use ArrayObject; use Carbon\Carbon as BaseCarbon; use Illuminate\Support\Carbon; @@ -239,4 +240,14 @@ public function testForeignIdConstrainedNullable(Address $address): ?int { return $address->nullable_foreign_id_constrained; } + + public function testCustomCast(): Favorites + { + return $this->user->favorites; + } + + public function testInboundCast(): void + { + $this->user->secret = 'secret'; + } } diff --git a/tests/Type/data/model-properties.php b/tests/Type/data/model-properties.php index 9d3739939..2d28a26e8 100644 --- a/tests/Type/data/model-properties.php +++ b/tests/Type/data/model-properties.php @@ -3,6 +3,9 @@ namespace ModelProperties; use App\User; +use Carbon\Carbon; +use Carbon\CarbonImmutable; +use Illuminate\Support\Collection; use function PHPStan\Testing\assertType; /** @var User $user */ @@ -10,4 +13,27 @@ assertType('int', $user->stringButInt); assertType('string', $user->email); assertType('array', $user->allowed_ips); -assertType('string', $user->floatButRoundedDecimalString); +assertType('numeric-string', $user->floatButRoundedDecimalString); + +// Model Casts +assertType('int', $user->int); +assertType('int', $user->integer); +assertType('float', $user->real); +assertType('float', $user->float); +assertType('float', $user->double); +assertType('numeric-string', $user->decimal); +assertType('string', $user->string); +assertType('bool', $user->bool); +assertType('bool', $user->boolean); +assertType('stdClass', $user->object); +assertType('array', $user->array); +assertType('array', $user->json); +assertType(Collection::class, $user->collection); +assertType(Carbon::class, $user->date); +assertType(Carbon::class, $user->datetime); +assertType(CarbonImmutable::class, $user->immutable_date); +assertType(CarbonImmutable::class, $user->immutable_datetime); +assertType('int', $user->timestamp); + +// CastsAttributes +assertType('App\ValueObjects\Favorites', $user->favorites);