Ian's Website

Blog Portfolio Current

PHPStan

I've been using PHPStan at work lately, it's been a great experience. I've found the type system to be surprisingly expressive and powerful, so I'm just going to run down some features and how I've been using them.

Larastan

Laravel has quite a bit of magic which plain PHPStan doesn't catch. But the PHPStan extension larastan does an incredible job of resolving types for Laravel's magic. For example, since Laravel models don't directly declare properties, there's no way to find them without executing the casts() function, or declaring them with @property, or through a getter/setter. But, if you show that casts() returns a static value, for example

class MyModel extends Model {
    // [...]
    /**
     * @return array{ 'created_at': 'immutable_datetime' }
     */
    public function casts() {
        return [ 'created_at' => 'immutable_datetime' ]
    }
    // [...]
}

Then larastan will pick up on that and accessing MyModel.created_at later will automatically be typed as CarbonImmutable! It can even pick up on custom properties.

In a similar vein, it can pick up on complex relations, even including typed pivot values. There's some information lost, especially when models are partially loaded. So, for example if I have Post, Author and a PostAuthor pivot between the two, then declare the relation on Post as:

class Post {
    // [...]

    /**
     * @return BelongsToMany<Author, $this, PostAuthor>
     */
    public function authors(): BelongsToMany
    {
        return $this->belongsToMany(Author::class, PostAuthor::class)
            ->using(PostAuthor::class)
            ->withPivot(['role'])
            ->withTimestamps();
    }
    // [...]
}

Now, if we go to access the relation on a post:

Post $p = // [...];
Author $author = $p->authors->first();

// this is allowed
PostAuthor $authorPivot = $author->pivot;

// BUT if we omit '->withPivot(['role'])' in the declaration above this will be null, even if it's a required column
$authorPivot->role;

So, you'll probably want to go and declare all your properties nullable if they aren't already from the cast. Not great!

Generics

The main reason I wanted to use PHPStan was to support generics:

/** @var list<int> $a */
$a = [];

/** @var list<string> $b */
$b = $a; // doesn't type check now!

It's a small thing but something I've really missed since PHP doesn't support generics natively.

Conditionals

Conditional expressions on types are allowed, so far I've seen this used for handling return types based on a config flag:

/**
 *
 * @param string $str
 * @return ($castToInt is true ? int : string)
 */
function maybeTransform(string $str, bool $castToInt): int|string {
}

// $a is of type `int`
$a = maybeTransform('a', true);
// $b is of type `string`
$b = maybeTransform('b', false);
// $c is of type `string|int`
$c = maybeTransform('b', mt_rand(0, 1) === 0); 

Assertions

PHPStan allows asserting some type, assuming a function doesn't throw, for example we have a util:

/**
 * @template T
 * @param  class-string<T>  $expectedClass
 * @phpstan-assert T $var
 */
public static function instanceOf(mixed $var, string $expectedClass, ?string $variableName = null): void
{
    if (! ($var instanceof $expectedClass)) {
        throw new \Error('unexpected type...');
    }
}

after calls to instanceOf, if it doesn't throw PHPStan considers @var a T. I think this showcases a nice feature too with graceful class-string<T> handling, where we can say "this variable stores a particular type name", then go on to use that type name as T elsewhere in PHPDoc and PHPStan comments.

Overall...

it's not perfect, but I've found PHPStan to be a huge quality-of-life improvement in writing PHP. If for no other reason than the VSCode extension gives better type hints than Intelephense.