Pragmatism in today's world

PHPStan custom rules

Recently I discovered that this code passed our PHPStan level 10 checks:

use http\Exception\InvalidArgumentException;


// ...

throw new InvalidArgumentException;

I was surprised as http\Exception\InvalidArgumentException is not a class in our system. While cooling, I discovered that there’s an http PHP extension and it appears that PHPStan has a stub for this which means that it accepts it as existing even if it doesn’t.

What we think happened is that I wrote throw new InvalidArgumentException; and selected the wrong class from the dropdown in PHPStorm as I wanted to import the base InvalidArgumentExtension

I want PHPStan to fail in this case, so I wrote a PHPStan extension that calls class_exists() to ensure that the class really is available:

<?php

declare(strict_types=1);

namespace Laminar\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Stmt\Use_;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleError;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\ShouldNotHappenException;

/**
 * Flags imported classes that PHPStan knows about via bundled extension stubs
 * but cannot be loaded in the current runtime environment.
 *
 * This rule checks using the xxx_exists() functions, so is only useful if run within
 * a context that matches deployment.
 *
 * @implements Rule<Use_>
 */
class ImportedClassExistsRule implements Rule
{
    public function __construct(private readonly ReflectionProvider $reflectionProvider)
    {
    }

    public function getNodeType(): string
    {
        return Use_::class;
    }

    /**
     * @return list<RuleError>
     * @throws ShouldNotHappenException
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if ($node->type !== Use_::TYPE_NORMAL) {
            return [];
        }

        $errors = [];

        foreach ($node->uses as $use) {
            $className = $use->name->toString();

            if (! $this->reflectionProvider->hasClass($className)) {
                continue;
            }

            if (
                class_exists($className)
                || interface_exists($className)
                || trait_exists($className)
                || enum_exists($className)
            ) {
                continue;
            }

            $errors[] = RuleErrorBuilder::message(
                sprintf(
                    'Imported class %s does not exist at runtime. The required PHP extension may not be installed.',
                    $className,
                ),
            )->identifier('akrabat.importedClassExists')
            ->line($use->getLine())
            ->build();
        }

        return $errors;
    }
}

The only important thing you need to know about writing a custom PHPStan rule is that processNode() returns an array of errors and if that is empty, then the rule succeeded.

This one isn’t particularly complicated, but it solves the problem!

As an aside, to prevent PhpStorm from providing autocomplete for extensions that don’t exist in your application, go to Settings→PHP and select the PHP Runtime tab. You can then select the extensions you use and deselect those that you don’t. There’s even a “Sync Extensions with Interpreter” button which can be handy if you build your dev container from your prod one.

Thoughts? Leave a reply

Your email address will not be published. Required fields are marked *