DEV Community

Cover image for Writing a PHPStan Extension: Teaching the Analyzer About Your Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Writing a PHPStan Extension: Teaching the Analyzer About Your Domain


You wrote a typed config repository. $config->get('mailer.retries') should be an int. $config->get('mailer.transport') should be a string. You know that. Your team knows that. PHPStan sees mixed, and every line that touches the result lights up at level 8.

So you paper over it. assert(is_int($retries)) here, a @var int doc block there. The type information you already have, encoded in the method's key argument, gets thrown away and re-asserted by hand at every call site.

PHPStan can read that key. You have to teach it how. That is what extensions are for: giving the analyzer knowledge about your code that it can't infer from signatures alone.

Where generics stop and extensions start

Reach for generics first. A Collection<T> with @template T and a first(): T covers most of what people want when they say "PHPStan doesn't understand my collections." A factory that takes class-string<T> and returns T needs no extension either. The type system already handles the class-string-to-instance case.

Extensions earn their place when the return type depends on the value of an argument, not just its type. A config key that is a literal string. A query bus that dispatches based on the message class. A service locator keyed by string IDs. Generics can't express "when the first argument is the literal 'mailer.retries', the result is int." An extension can.

There are two kinds worth knowing:

  • Dynamic return type extensions compute a method's return type from its call site.
  • Type-specifying extensions narrow the type of a variable after a call, the way is_int() narrows inside an if.

Both plug into PHPStan through its service container. You register a class, tag it, and the analyzer picks it up.

A dynamic return type extension for your config

Here is the config class PHPStan can't read on its own:

namespace App\Config;

final class Config
{
    /** @param array<string, mixed> $values */
    public function __construct(private array $values) {}

    public function get(string $key): mixed
    {
        return $this->values[$key] ?? null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The extension implements DynamicMethodReturnTypeExtension. Three methods: which class it targets, which method it handles, and the type computation itself.

namespace App\PHPStan;

use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;

final class ConfigGetExtension implements
    DynamicMethodReturnTypeExtension
{
    /** @var array<string, Type> */
    private array $schema;

    public function __construct()
    {
        $this->schema = [
            'mailer.transport' => new StringType(),
            'mailer.retries' => new IntegerType(),
        ];
    }

    public function getClass(): string
    {
        return \App\Config\Config::class;
    }

    public function isMethodSupported(
        MethodReflection $methodReflection,
    ): bool {
        return $methodReflection->getName() === 'get';
    }
Enter fullscreen mode Exit fullscreen mode

The first three methods are wiring. getTypeFromMethodCall is where the call site gets read:

    public function getTypeFromMethodCall(
        MethodReflection $methodReflection,
        MethodCall $methodCall,
        Scope $scope,
    ): ?Type {
        $args = $methodCall->getArgs();
        if ($args === []) {
            return null;
        }

        $keyType = $scope->getType($args[0]->value);
        if (!$keyType instanceof ConstantStringType) {
            return null;
        }

        return $this->schema[$keyType->getValue()] ?? null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The important call is $scope->getType($args[0]->value). Scope is PHPStan's view of what every expression is at that point in the code. When the key is a literal, getType() hands you a ConstantStringType whose getValue() is the exact string. You map it to a Type object and return it.

Return null when you don't recognize the key or the argument isn't a literal. That tells PHPStan to fall back to the method's declared return type. Your extension only speaks up when it has something to say, which keeps it from breaking calls it wasn't built for.

Registering it

Extensions live in the PHPStan service container. Add the class to your phpstan.neon with the matching tag:

services:
    -
        class: App\PHPStan\ConfigGetExtension
        tags:
            - phpstan.broker.dynamicMethodReturnTypeExtension
Enter fullscreen mode Exit fullscreen mode

There are sibling interfaces for the other call shapes. Static methods use DynamicStaticMethodReturnTypeExtension with the phpstan.broker.dynamicStaticMethodReturnTypeExtension tag. Plain functions use DynamicFunctionReturnTypeExtension. The shape of each is the same: identify the target, decide if you handle the call, compute a type from the arguments.

Run the analyzer and the mixed noise is gone:

$retries = $config->get('mailer.retries');
$next = $retries + 1;   // int, no assertion needed
Enter fullscreen mode Exit fullscreen mode

Type-specifying extensions: teach it about your guards

The other half of the problem is narrowing. You have a guard clause that throws when a value is null:

namespace App\Support;

use InvalidArgumentException;

final class Guard
{
    public static function notNull(
        mixed $value,
        string $message = 'unexpected null',
    ): void {
        if ($value === null) {
            throw new InvalidArgumentException($message);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You use it to peel the null off a repository result:

$order = $this->orders->find($id);   // ?Order
Guard::notNull($order);
$order->markShipped();               // still ?Order to PHPStan
Enter fullscreen mode Exit fullscreen mode

After Guard::notNull($order), the value cannot be null. You know that because the method throws otherwise. PHPStan doesn't, so line three still fails at level 8. A type-specifying extension closes the gap.

It implements StaticMethodTypeSpecifyingExtension and TypeSpecifierAwareExtension. The second interface exists so PHPStan can inject the TypeSpecifier you need to build the narrowed type.

namespace App\PHPStan;

use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\SpecifiedTypes;
use PHPStan\Analyser\TypeSpecifier;
use PHPStan\Analyser\TypeSpecifierAwareExtension;
use PHPStan\Analyser\TypeSpecifierContext;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
use PHPStan\Type\TypeCombinator;

final class GuardNotNullExtension implements
    StaticMethodTypeSpecifyingExtension,
    TypeSpecifierAwareExtension
{
    private TypeSpecifier $typeSpecifier;

    public function setTypeSpecifier(
        TypeSpecifier $typeSpecifier,
    ): void {
        $this->typeSpecifier = $typeSpecifier;
    }

    public function getClass(): string
    {
        return \App\Support\Guard::class;
    }
Enter fullscreen mode Exit fullscreen mode

The setup is the two-interface boilerplate. The logic lives in the two methods that follow:

    public function isStaticMethodSupported(
        MethodReflection $methodReflection,
        StaticCall $node,
        TypeSpecifierContext $context,
    ): bool {
        return $methodReflection->getName() === 'notNull'
            && $context->null()
            && isset($node->getArgs()[0]);
    }

    public function specifyTypes(
        MethodReflection $methodReflection,
        StaticCall $node,
        Scope $scope,
        TypeSpecifierContext $context,
    ): SpecifiedTypes {
        $expr = $node->getArgs()[0]->value;
        $type = TypeCombinator::removeNull(
            $scope->getType($expr),
        );

        return $this->typeSpecifier->create(
            $expr,
            $type,
            TypeSpecifierContext::createTruthy(),
            $scope,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details carry the logic. $context->null() in isStaticMethodSupported means the call stands on its own line, not inside an if. That is how an assertion behaves: it throws or it continues, so narrowing applies to the code that follows. And TypeCombinator::removeNull() does the actual work of taking Order|null down to Order.

Register it with the type-specifier tag:

services:
    -
        class: App\PHPStan\GuardNotNullExtension
        tags:
            - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension
Enter fullscreen mode Exit fullscreen mode

Now the markShipped() call passes. Every guard clause you already wrote starts pulling its weight in static analysis, instead of being invisible to it.

When not to write one

An extension is code that runs on every analysis. It has a maintenance cost, and a buggy one gives you false confidence, which is worse than mixed. Skip it when:

  • Generics already fit. @template on a collection or factory beats an extension every time. Try that first.
  • A community extension exists. phpstan/phpstan-webmozart-assert, larastan, phpstan-doctrine, and phpstan-symfony already teach the analyzer about their libraries. Don't rebuild them.
  • The relationship is a one-off. For a single odd return, an inline @var or a narrowing assert() is cheaper than a class in your container.

Write the extension when the pattern repeats across your domain and the type is knowable from the call. A config schema, a message bus, a value-object guard used in fifty services. Those pay back the class you wrote.

Test it before you trust it

PHPStan ships PHPStanTestCase and rule-test harnesses so you can assert on real fixtures. Point a test at a file that exercises $config->get('mailer.retries') and assert the inferred type is int. An extension that silently returns the wrong type is a slow leak. A test on the fixture catches it the day someone changes the schema.


A config lookup, a guard clause, a factory: these are boundary concerns. The extension keeps the knowledge about those boundaries at the edge, in a small analyzer class, instead of smearing assert() calls through your domain logic. That is the same instinct hexagonal architecture is built on. Push the framework-shaped and infrastructure-shaped concerns to adapters at the edge, and keep the core clean enough that both you and the analyzer can read its intent. Decoupled PHP is the long-form version of that argument, from ports and adapters down to the folder layout.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)