In PHPStan, how should a plain string from config become a class-string: narrowed with class_exists() or forced with an inline @var?
answer
- prove it rather than assert it
- class_exists() narrows to class-string
- inline @var is always trusted
- 2.0: @var checked against native type
- fix at source: stubs, generics, assert()
basics
~20 sNarrow it: after if (class_exists($name)) PHPStan treats $name as class-string, and the check also protects runtime. An inline @var is trusted without proof, so the docs call it a last resort and prefer fixing the type at its source.
solid answer
~50 s`class-string` is a PHPDoc type for a string holding a valid class name; `class-string<Foo>` accepts only names of `Foo` or its subtypes. A literal like `'stdClass'` or `Foo::class` satisfies it, but a string from config does not. The right fix is to **narrow**: inside `if (class_exists($name))` PHPStan knows `$name` is `class-string`, and the same check stops a bad config value at runtime. For a specific parent, a check such as `is_a($name, Foo::class, true)` goes further. An inline `/** @var class-string<Foo> $name */` instead **asserts** the type: PHPStan trusts it, so a mistake silently corrupts the analysis below, and nothing is checked at runtime. Since PHPStan 2.0 inline `@var` is validated against the native type (*PHPDoc tag @var with type X is not subtype of native type Y*), but that only catches contradictions. The docs say to use it as a last resort; fix the type at its source with a stub file, generics or a return type, or use `assert()`.
code
php · 13 lines<?php
declare(strict_types=1);
function loadReport(string $name): object
{
if (!class_exists($name)) {
throw new InvalidArgumentException("No report class $name");
}
// $name is class-string here, for PHPStan and in fact
return new $name();
}go deeper
Know that class-string means a string holding a class name, and that ::class constants satisfy it.
Narrow strings with class_exists() and explain why inline @var is trusted without proof.
Know PHPStan 2's @var validation messages and their limits, and fix types at the source with stubs, generics or assert().
Set a team rule that inline @var needs justification in review, and track it as a signal of missing types upstream.
## What class-string means PHPStan's `class-string` is a **refined string type**: a string that contains the name of an existing class. `class-string<Foo>` further restricts it to `Foo` and its subtypes. `interface-string` and `trait-string` are aliases for `class-string`, and `enum-string` accepts enum class names. Typical uses: ```php /** * @template T of object * @param class-string<T> $class * @return T */ function make(string $class): object { return new $class(); } ``` Literal names (`'stdClass'`) and `::class` constants (`\stdClass::class`) satisfy `class-string`. A general `string`, such as a value read from a config file or a request, does not, and passing it produces an argument type error. ## Option 1: narrow it ```php function handlerFor(string $name): object { if (!class_exists($name)) { throw new InvalidArgumentException("Unknown handler class: $name"); } return make($name); // $name is class-string here } ``` - PHPStan's type-specifying extensions understand `class_exists()` (and `interface_exists()`): inside the true branch, or after the early `throw`, `$name` is `class-string`. - The check is **real code**, so a bad config value fails loudly at runtime instead of producing an obscure error from `new`. - For `class-string<Foo>`, a subtype check such as `is_a($name, Foo::class, true)`, whose third argument allows a class-name string, is also understood by PHPStan's `is_a()` extension. The same principle applies to other refined types: a comparison like `if ($page >= 1)` narrows an `int` towards `int<1, max>`. ## Option 2: force it with inline @var ```php /** @var class-string<Handler> $name */ $name = $config['handler']; ``` The PHPStan docs describe inline `@var` as a **last resort**, for two reasons: 1. **It is always trusted.** Because `@var` is often used to correct wrong type information, PHPStan believes it. If the annotation is wrong, everything analysed below it is wrong too, and no error points at the cause. 2. **It is repeated.** The cast has to be written above every place the value is used, instead of once where the type originates. ## What PHPStan 2 validates PHPStan 2.0 made inline `@var` validation standard. Two messages matter: | Message | Identifier | Meaning | |---|---|---| | *PHPDoc tag @var with type X is not subtype of native type Y.* | `varTag.nativeType` | the annotation contradicts what PHP itself guarantees, for example `@var Foo` on an `int` | | *PHPDoc tag @var with type X is not subtype of type Y.* | `varTag.type` | the annotation contradicts PHPStan's inferred type | These catch **contradictions**. They cannot catch an annotation that narrows `mixed` to something plausible but false, which is exactly the config-value case above. ## Fixing the type at its source The docs list better tools than inline `@var`: - A **stub file** when a third-party function or method declares a wrong or vague type. - **Generics** (`@template`) when the return type depends on the arguments, such as a container's `get(Foo::class)`. - A proper **`@return`** or native return type on your own code. - An **`assert($x instanceof Foo)`** call when you must narrow locally: PHPStan narrows on it, and PHP can check it at runtime when assertions are enabled. ## Other refined string types `class-string` is one of several refinements of `string` that PHPStan understands, and the same narrow-don't-assert rule applies to all of them: - `non-empty-string`: any string except `''`. - `numeric-string`: a string that would pass `is_numeric()`. - `non-falsy-string`: a string that is `true` after casting to bool. - `literal-string`: a security-focused type for strings written by a developer or composed only of developer-written strings, useful for query builders that must never receive user input. - `lowercase-string`, `decimal-int-string` and `non-decimal-int-string` (the last two new in 2.2 for safer array keys). Each can be reached by a check PHPStan understands, which is always preferable to an inline cast. ## Summary for an interview | | Narrowing (`class_exists`, `is_a`, `instanceof`) | Inline `@var` | |---|---|---| | Proves the type | yes | no, asserts it | | Runtime protection | yes | none | | Checked by PHPStan | the check itself is analysed | only for contradictions (2.0+) | | Repetition | once, at the check | at every use | A strong answer prefers proving the type in code, reaches for `@var` only when no check or source fix is possible, and knows that PHPStan 2 validates it only against contradictions.
- PHPStan 2 reports 'PHPDoc tag @var with type Foo is not subtype of native type string'; what happened?An inline `@var` claims the variable is `Foo`, but PHP guarantees it is a `string`, so the annotation contradicts the native type. Since 2.0 PHPStan validates inline `@var` and reports this under `varTag.nativeType`. Fix the real type, or remove the wrong annotation.
- A vendor library's method is declared to return mixed but always returns a Money object; what is better than @var at every call?A stub file that redeclares that method with `@return Money`, registered in phpstan.neon. The correction lives in one place, applies to every call site, and your own code needs no inline casts.
saying these in an interview costs you the question
- An inline @var is verified at runtime by PHP
- Any string satisfies class-string in PHPStan
- PHPStan 2 proves every inline @var is correct
- class_exists() has no effect on PHPStan's types
- Repeating @var at each call is better than a stub file