Static analysis

August 10, 2026 · View on GitHub

The premise of this package is that you write ordinary PHPStan-style generic code and the engine enforces it at run time. This document is the other half of that: what the shipped PHPStan extension infers, what it refuses to let you write, and why a placeholder-form template needs a generated stub at all.

Installation

With phpstan/extension-installer there is nothing to do. Without it, include the extension by hand:

includes:
    - vendor/lisachenko/userland-php-generics/extension.neon

What it infers

$box = new (Box::of('int'))();   // Box<int>
Box::of('int');                  // class-string<Box<int>>
Generic::specialize(Box::class, 'int');  // class-string<Box<int>>
Generic::new(Box::class, ['int']);       // Box<int>

new (expr)() is ordinary PHP, and PHPStan resolves new on a class-string<X> to X — so getting the class-string right is the whole job, and nothing has to be annotated at the call site.

Type arguments are resolved through PHPStan's own type parser rather than a second implementation, which is why the grammar was deliberately made a subset of PHPStan's. A nested Box<Box<int>> therefore works with no extra code.

Inference degrades rather than guesses. When an argument is not a literal string, or the number of arguments does not match the @template tags, the extension returns nothing and PHPStan keeps the declared return type:

Box::of($runtime);          // class-string<Box> — not narrowed, and not wrong
Box::of('int', 'string');   // class-string<Box> — arity mismatch, the runtime will say so

A confidently wrong inferred type makes the analyser lie, which is worse than the unhelpful one it replaced. Those cases are asserted in the test suite on purpose.

What the rules catch

RuleCatchesBecause
InstanceofGenericTemplateRule$x instanceof BoxA specialization is a sibling of its template, so this is always false
CatchGenericTemplateRulecatch (Box $e)Same relation, worse failure: a catch that never matches lets the exception keep travelling
TemplateParameterConsistencyRule#[TemplateParameter] and @template disagreeingThe runtime reads one and the analyser reads the other, and nothing in the language keeps them in step
UnsupportedTypeArgumentRuleBox::of('iterable'), '?int', 'int|string'No zend_type slot can hold them; the messages come from the same table the runtime throws from
SlotAttributeRule#[Of('T')] naming an undeclared parameter, or marking an untyped or composite slotThe same checks TemplateParser makes, moved to where you are typing
SelfClassInTemplateRuleself::class inside a templateIt names the template: bodies are shared, and the compiler folded that constant in
PropertyHooksInTemplateRuleProperty hooks on a templateThe specializer refuses the class outright, so it could never be specialized

The first is the one worth installing the extension for on its own. instanceof against a template is the single most surprising thing about this design, nothing about the call site looks wrong, and without a rule the only way to discover it is to ship it.

Generated stubs, and why they are needed

A placeholder-form template declares the type parameter as its native type:

/** @template T */
#[TemplateParameter('T')]
final class Box implements GenericObject
{
    private ?T $value = null;
    public function set(T $value): void { /* ... */ }
}

That is exactly what the engine needs — substitution keys on the type name — and exactly the opposite of what an analyser needs. PHPStan resolves the native T to an object type and lets it beat any @param T, so every call site on a specialization reads expects App\T, int given.

A stub file replaces the declaration for analysis, which makes it the only mechanism that can describe the class the way it actually behaves. Generate them:

vendor/bin/generics-stubs --out=var/generics-stubs 'App\Box' 'App\Map'

and point PHPStan at the result:

parameters:
    stubFiles:
        - var/generics-stubs/box-stub.php
        - var/generics-stubs/map-stub.php
    scanFiles:
        - var/generics-stubs/placeholders.php

--check writes nothing and exits non-zero if any stub is out of date, which is what to run in CI if you commit them.

The attribute form needs no stubs for its properties, since they are natively mixed already. That is the concrete measure of its advantage.

Two constraints the generator obeys

Both were learned the hard way and are not negotiable:

  • One class per stub file. PHPStan indexes only the first class declaration in a stub and silently ignores the rest, so a file with three classes describes one.
  • A stub cannot name your own interfaces or traits. Stub files are reflected before the analysed paths are indexed. That is why the generated of() is written out in full rather than inherited from GenericTemplate.

PHP's own interfaces are the exception to the second one, and the generator does declare them: $vector[0], count($vector) and foreach are only legal in analysed code if the stub says the class is an ArrayAccess, a Countable and an IteratorAggregate. A generic interface has to say what it was parameterized with, and only the template knows — so the generator copies the @implements tags off the template's own class doc comment, which is where this package keeps everything static analysis needs. Everything else about a stub is read from reflection: visibility, class constants, parameter defaults and static named constructors are all reproduced, because analysed code that names one has to find it.

The stub replaces the declaration, so keep PHPStan from reading both

A stub only wins if the analyser is not also reading the real file. In your own project the template lives in a path you analyse, so exclude it from scanning, not just from analysis:

parameters:
    excludePaths:
        analyseAndScan:
            - src/Box.php
    stubFiles:
        - var/generics-stubs/box-stub.php

The price of doing that is stated in limitations.md: the stubbed class is not a GenericObject, so Box::of('int') no longer narrows and the specialization has to be spelled once, in the @var or @param where it enters your code.

The placeholders file

The placeholder types never exist at run time — an undefined class name is precisely what gives the engine something to substitute — but PHPStan has to resolve them to analyse the template's own file at all. placeholders.php declares them for analysis only, and belongs in scanFiles rather than stubFiles.

This package analyses itself with its own extension

phpstan.dist.neon includes extension.neon, and the stubs in tests/phpstan/generated/ are produced by composer stubs:generate and committed. composer stubs:check runs in CI and fails on any diff. A third job runs composer test:analysis with ffi.enable=0, so the claim that the analysis layer needs no engine is tested rather than asserted.

That is deliberate rather than tidy: it means a rule that crashes, a service that cannot be wired, or a generator change that stops producing what the project relies on all fail the build here rather than in somebody's project. It has already earned its place — the first run reported SlotAttributeRule firing on a fixture that exists precisely to be invalid.