Skip to content

Commit 66db15f

Browse files
committed
feat: support Laravel 11 casts method
closes #1877
1 parent 1a05ac6 commit 66db15f

File tree

5 files changed

+170
-13
lines changed

5 files changed

+170
-13
lines changed

src/Properties/ModelCastHelper.php

+74
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@
1515
use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
1616
use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
1717
use Illuminate\Database\Eloquent\Casts\AsStringable;
18+
use Illuminate\Database\Eloquent\Model;
1819
use Illuminate\Support\Arr;
1920
use Illuminate\Support\Carbon as IlluminateCarbon;
2021
use Illuminate\Support\Collection;
2122
use Illuminate\Support\Facades\Date;
2223
use Illuminate\Support\Stringable as IlluminateStringable;
24+
use PHPStan\Analyser\OutOfClassScope;
25+
use PHPStan\Reflection\ClassReflection;
26+
use PHPStan\Reflection\MissingMethodFromReflectionException;
2327
use PHPStan\Reflection\ParameterReflection;
2428
use PHPStan\Reflection\ParametersAcceptorSelector;
2529
use PHPStan\Reflection\ReflectionProvider;
30+
use PHPStan\ShouldNotHappenException;
2631
use PHPStan\Type\Accessory\AccessoryNumericStringType;
2732
use PHPStan\Type\ArrayType;
2833
use PHPStan\Type\BenevolentUnionType;
@@ -36,14 +41,24 @@
3641
use PHPStan\Type\StringType;
3742
use PHPStan\Type\Type;
3843
use PHPStan\Type\TypeCombinator;
44+
use ReflectionException;
3945
use stdClass;
4046
use Stringable;
4147

48+
use function array_combine;
49+
use function array_key_exists;
50+
use function array_map;
51+
use function array_merge;
4252
use function class_exists;
4353
use function explode;
54+
use function str_replace;
55+
use function version_compare;
4456

4557
class ModelCastHelper
4658
{
59+
/** @var array<string, array<string, string>> */
60+
private array $modelCasts = [];
61+
4762
public function __construct(
4863
protected ReflectionProvider $reflectionProvider,
4964
) {
@@ -193,4 +208,63 @@ private function parseCast(string $cast): string
193208

194209
return $cast;
195210
}
211+
212+
public function hasCastForProperty(ClassReflection $modelClassReflection, string $propertyName): bool
213+
{
214+
if (! array_key_exists($modelClassReflection->getName(), $this->modelCasts)) {
215+
$modelCasts = $this->getModelCasts($modelClassReflection);
216+
} else {
217+
$modelCasts = $this->modelCasts[$modelClassReflection->getName()];
218+
}
219+
220+
return array_key_exists($propertyName, $modelCasts);
221+
}
222+
223+
public function getCastForProperty(ClassReflection $modelClassReflection, string $propertyName): string|null
224+
{
225+
if (! array_key_exists($modelClassReflection->getName(), $this->modelCasts)) {
226+
$modelCasts = $this->getModelCasts($modelClassReflection);
227+
} else {
228+
$modelCasts = $this->modelCasts[$modelClassReflection->getName()];
229+
}
230+
231+
return $modelCasts[$propertyName] ?? null;
232+
}
233+
234+
/**
235+
* @return array<string, string>
236+
*
237+
* @throws ShouldNotHappenException
238+
* @throws MissingMethodFromReflectionException
239+
*/
240+
private function getModelCasts(ClassReflection $modelClassReflection): array
241+
{
242+
try {
243+
/** @var Model $modelInstance */
244+
$modelInstance = $modelClassReflection->getNativeReflection()->newInstanceWithoutConstructor();
245+
} catch (ReflectionException) {
246+
throw new ShouldNotHappenException();
247+
}
248+
249+
$modelCasts = $modelInstance->getCasts();
250+
251+
if (version_compare(LARAVEL_VERSION, '11.0.0', '>=')) { // @phpstan-ignore-line
252+
$castsMethodReturnType = ParametersAcceptorSelector::selectSingle($modelClassReflection->getMethod(
253+
'casts',
254+
new OutOfClassScope(),
255+
)->getVariants())->getReturnType();
256+
257+
if ($castsMethodReturnType->isConstantArray()->yes()) {
258+
$modelCasts = array_merge(
259+
$modelCasts,
260+
array_combine(
261+
array_map(static fn ($key) => $key->getValue(), $castsMethodReturnType->getKeyTypes()), // @phpstan-ignore-line
262+
array_map(static fn ($value) => str_replace('\\\\', '\\', $value->getValue()), $castsMethodReturnType->getValueTypes()), // @phpstan-ignore-line
263+
),
264+
);
265+
}
266+
}
267+
268+
return $modelCasts;
269+
}
196270
}

src/Properties/ModelPropertyHelper.php

+13-13
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,19 @@ public function getDatabaseProperty(ClassReflection $classReflection, string $pr
106106
if ($this->hasDate($modelInstance, $propertyName)) {
107107
$readableType = $this->modelCastHelper->getDateType();
108108
$writableType = TypeCombinator::union($this->modelCastHelper->getDateType(), new StringType());
109-
} elseif ($modelInstance->hasCast($propertyName)) {
110-
$cast = $modelInstance->getCasts()[$propertyName];
111-
112-
$readableType = $this->modelCastHelper->getReadableType(
113-
$cast,
114-
$this->stringResolver->resolve($column->readableType),
115-
);
116-
$writableType = $this->modelCastHelper->getWriteableType(
117-
$cast,
118-
$this->stringResolver->resolve($column->writeableType),
119-
);
120109
} else {
121-
if (in_array($column->readableType, ['enum', 'set'], true)) {
110+
$cast = $this->modelCastHelper->getCastForProperty($classReflection, $propertyName);
111+
112+
if ($cast !== null) {
113+
$readableType = $this->modelCastHelper->getReadableType(
114+
$cast,
115+
$this->stringResolver->resolve($column->readableType),
116+
);
117+
$writableType = $this->modelCastHelper->getWriteableType(
118+
$cast,
119+
$this->stringResolver->resolve($column->writeableType),
120+
);
121+
} elseif (in_array($column->readableType, ['enum', 'set'], true)) {
122122
if ($column->options === null || count($column->options) < 1) {
123123
$readableType = $writableType = new StringType();
124124
} else {
@@ -227,6 +227,6 @@ private function hasDate(Model $modelInstance, string $propertyName): bool
227227
$dates[] = $modelInstance->getDeletedAtColumn();
228228
}
229229

230-
return in_array($propertyName, $dates);
230+
return in_array($propertyName, $dates, true);
231231
}
232232
}

tests/Type/GeneralTypeTest.php

+4
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ public static function dataFileAsserts(): iterable
7878
yield from self::gatherAssertTypes(__DIR__ . '/data/bug-1819.php');
7979
}
8080

81+
if (version_compare(LARAVEL_VERSION, '11.0.0', '>=')) {
82+
yield from self::gatherAssertTypes(__DIR__ . '/data/model-properties-l11.php');
83+
}
84+
8185
//##############################################################################################################
8286

8387
// Console Commands
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace ModelPropertiesL11;
4+
5+
use Illuminate\Database\Eloquent\Casts\AsStringable;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Support\Stringable;
8+
use function PHPStan\Testing\assertType;
9+
10+
function test(ModelWithCasts $modelWithCasts): void
11+
{
12+
assertType('bool', $modelWithCasts->integer);
13+
assertType(Stringable::class, $modelWithCasts->string);
14+
}
15+
16+
class ModelWithCasts extends Model
17+
{
18+
protected $casts = [
19+
'integer' => 'int',
20+
];
21+
22+
/**
23+
* @return array{integer: 'bool', string: 'Illuminate\\Database\\Eloquent\\Casts\\AsStringable:argument'}
24+
*/
25+
public function casts(): array
26+
{
27+
$argument = 'argument';
28+
29+
return [
30+
'integer' => 'bool', // overrides the cast from the property
31+
'string' => AsStringable::class.':'.$argument,
32+
];
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Database\Migrations;
6+
7+
use Illuminate\Database\Migrations\Migration;
8+
use Illuminate\Database\Schema\Blueprint;
9+
10+
class CreateModelWithCastsTable extends Migration
11+
{
12+
/**
13+
* Run the migrations.
14+
*/
15+
public function up(): void
16+
{
17+
Schema::create('model_with_casts', static function (Blueprint $table) {
18+
$table->bigIncrements('id');
19+
20+
// Testing property casts
21+
$table->integer('int');
22+
$table->integer('integer');
23+
$table->float('real');
24+
$table->float('float');
25+
$table->double('double');
26+
$table->decimal('decimal');
27+
$table->string('string');
28+
$table->boolean('bool');
29+
$table->boolean('boolean');
30+
$table->json('object');
31+
$table->json('array');
32+
$table->json('json');
33+
$table->json('collection');
34+
$table->json('nullable_collection')->nullable();
35+
$table->date('date');
36+
$table->dateTime('datetime');
37+
$table->date('immutable_date');
38+
$table->dateTime('immutable_datetime');
39+
$table->timestamp('timestamp');
40+
41+
$table->timestamps();
42+
$table->softDeletes();
43+
});
44+
}
45+
}

0 commit comments

Comments
 (0)