plugins/nette-dev/skills/phpstan-analysis/SKILL.md
Invoke BEFORE running PHPStan or fixing PHPStan errors. Covers error resolution strategy (refactoring > phpDoc > ignoring), common Nette error patterns, baseline management, and type tests. Use this whenever the user mentions PHPStan, static analysis, type errors, wants to suppress warnings, or manage the baseline - even for a single error.
npx skillsauth add nette/claude-code phpstan-analysisInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
# Run with project configuration
vendor/bin/phpstan analyse
# Run on specific paths
vendor/bin/phpstan analyse src/foo/ src/bar.php
# Generate baseline for legacy projects
vendor/bin/phpstan analyse --generate-baseline
Never use --error-format=json - its output format can change between PHPStan versions and is not designed for stable machine consumption. For machine-readable output, use --error-format=raw.
Target level for Nette libraries is 8. Levels higher than 8 are not worth pursuing - the additional strictness (e.g., non-empty-string, positive-int) catches very few real bugs relative to the annotation burden.
Installed by all Nette libraries. Transparently narrows types and silences false positives — many PHPStan errors disappear without manual fixes. Don't add asserts, casts, or @var for errors that fall into these categories:
Strings::match(), Arrays::invoke(), Helpers::falseToNull(), Expect::array(), Html magic methods (setXxx/getXxx/addXxx), Container::getComponent() and $this['name'], Form $form['name']|false / |null removed where unrealistic (getcwd, json_encode, preg_*, intl/GD/DOM/etc.)Tester\Assert: notNull(), type(), true(), etc. narrow the typetest() / Assert::exception(), runtime variadic-closure type validation, Form event-handler callbacks with narrow data parameterTwo features require config in phpstan.neon (NOT app's common.neon):
Database row mapping — narrows Explorer::table(), ActiveRow::related(), ::ref() to concrete row classes. Keys may contain a single * wildcard; a bare * is the catch-all and substitutes PascalCase of the table name into * in the value. Exact keys win over wildcards; wildcards are tried in declaration order.
parameters:
nette:
database:
mapping:
tables:
booking: App\Entity\BookingRow # exact match
event_*: App\Entity\Event*Row # event_video → EventVideoRow
*: App\Entity\*Row # catch-all fallback
Asset type narrowing — narrows Registry::getMapper() / getAsset() / tryGetAsset() (and FilesystemMapper::getAsset() / ViteMapper::getAsset()) based on mapper ID and file extension. Values file and vite are shortcuts for the built-in FilesystemMapper / ViteMapper; any other value is treated as an FQCN of a custom mapper class.
parameters:
nette:
assets:
mapping:
default: file # FilesystemMapper
images: file
vite: vite # ViteMapper
custom: App\MyMapper # custom mapper FQCN
Full reference: https://doc.nette.org/en/best-practices/phpstan-rules
Resolve every error in this order of preference. Only fall back to the next step when the current one genuinely doesn't apply — this ladder is the backbone of the whole skill:
assert() - sparingly, only when the type cannot be expressed otherwisephpstan.neon - for systematic or intentional patterns, always with a comment explaining whyTwo hard rules override the ladder at every step:
@phpstan-ignore annotations - keep checker-specific directives out of source code; ignore in phpstan.neon instead.Before making any changes, create a plan:
property.nonObject, method.notFound, new.static, etc.)Always ask: does this error reveal a real design issue? Examples:
mixed or object but always returns a specific type; narrow the return type__get/__set where typed properties would workThe goal is not to "make PHPStan happy" but to use its feedback as a catalyst for better code.
The code worked before. A fix that hides an error degrades code quality.
// Before - json_encode returns false on error and we find out (type error)
function foo(): string {
return json_encode($this->value);
}
// WRONG - error is hidden
function foo(): string {
return (string) json_encode($this->value);
}
Better solutions, in order: use Json::encode(), or add an explicit check that throws. Only when neither applies, fall back to the baseline (last resort, per the resolution priority).
// Before - fopen can return false
$f = fopen($file, 'r');
// Correct fix
$f = fopen($file, 'r') ?: throw new IOException("Cannot open file $file");
/** @var */ in Method Bodies/** @var Type */ in method body is taken authoritatively by PHPStan - it completely disables type checking for that variable. Use only when no better solution exists.
callable into callable(...mixed): mixedWhen PHPStan reports a missing callable signature (missingType.callable), it's tempting to write callable(...mixed): mixed. Don't. That type is narrower than bare callable, not wider, because of parameter contravariance:
callable(...mixed): mixed claims the callee may be invoked with any arguments, so only callbacks whose parameters are all mixed-compatible (or which have no required typed params) satisfy it.function (UiForm $form, mixed $value): void {} is then rejected at call sites: expects callable(mixed...): mixed, Closure(UiForm, mixed): void given.Bare callable is PHPStan's top type for callables — it accepts anything invokable regardless of signature, and $cb(...$args) inside the function still type-checks. So for "invoke arbitrary user callbacks" APIs keep callable (e.g. @param iterable<callable> $callbacks) and, if missingType.callable fires, ignore it for that file in phpstan.neon with a comment. An explicit signature buys nothing and introduces false positives. (Confirmed in nette/utils Arrays::invoke().)
The same applies to bare \Closure — when the value is guaranteed to be a closure (stored property, result of Closure::fromCallable(), etc.) but its signature is unknown or intentionally polymorphic, use plain \Closure without parameters. missingType.callable then fires on it too and is ignored on the same grounds.
Readability tip — wrap typed callables in parentheses. When a callable/Closure type has a signature and appears in a union or alongside other type fragments, wrap it in (...) so the reader can see where the signature ends:
// Hard to parse — does |null belong to the return type, or to the whole callable?
/** @var callable(): void|null $cb */
// Clear — the callable is one alternative, null is the other
/** @var (callable(): void)|null $cb */
// Same for Closure
/** @var (\Closure(int): string)|null $cb */
The parentheses are purely cosmetic for PHPStan (it parses both forms identically), but they save the reader from re-reading the line.
?: operator with falsy valuesThe ?: operator treats 0, 0.0, '0', '', [], null, and false as falsy. This is dangerous when the value can legitimately be '0' or 0:
// BUG - returns $default when $value is '0' or 0
$result = $value ?: $default;
// Correct - only replaces null
$result = $value ?? $default;
For phpDoc conventions (when to skip docs, array types, writing style), see the php-doc skill.
Key rules specific to PHPStan compatibility:
| Native Type | Wrong phpDoc | Correct phpDoc |
|-------------|--------------|----------------|
| array\|string | mixed[] | mixed[]\|string |
| array\|null | int[] | int[]\|null |
| object\|array | stdClass | stdClass\|array |
positive-int, non-empty-string, non-empty-array, non-falsy-string) - they rarely catch real bugs but add significant annotation maintenance burdenclass-string<T>, array<string, Foo>, list<int>, array{name: string, age: int} - these are useful for PHPStan and worth maintainingArray notation preference:
foo[] - always prefer for simple types (shortest notation)array<foo|bar> - for union types (more readable than (foo|bar)[])array<string, foo> or list<foo> - when keys are not genericproperty.nonObject - property access on array|objectIn DI Extensions, $this->config returns array|object but is stdClass. This is one of the legitimate cases for /** @var */ in method body - the type cannot be expressed otherwise:
public function loadConfiguration(): void
{
/** @var \stdClass $config */
$config = $this->config;
}
method.notFound / staticMethod.notFound / arguments.countCalling method on interface that exists only on implementation. Fix type if possible, or use assert():
$component = $container->getComponent($name);
assert($component instanceof Component);
$component->saveState($params);
Note: when accessing components via $this['name'] or $this->getComponent('name') with a constant string and a matching createComponent<Name>() factory on the same class, phpstan-rules narrows the type automatically — no assert needed. The assert pattern above applies when the name is dynamic or the factory lives elsewhere.
property.uninitializedReadonly / property.readOnlyAssignNotInConstructorReadonly properties initialized via inject methods (Nette DI pattern). Ignore - this is an intentional framework pattern.
new.static - unsafe usage of new static()If intentional design pattern (derive/factory methods), ignore in phpstan.neon. Or change to new self if subclassing isn't expected.
closure.unusedUseVariable in use ($var) used in require'd file. Ignore - false positive.
function.alreadyNarrowedTypePHPStan knows the type is already narrowed. Remove unnecessary condition, or ignore if it serves as runtime validation.
catch.neverThrownVerify if the catch is actually needed. If so, ignore.
Ignoring sits at the bottom of the resolution ladder (see "Resolution Priority") — exhaust refactoring, phpDoc, and assert() first. When ignoring is genuinely the right call, prefer phpstan.neon for systematic or intentional patterns (always with a comment) over phpstan-baseline.neon (last resort, minimize).
These rules apply to both phpstan.neon ignoreErrors and phpstan-baseline.neon.
MUST NOT be suppressed — always fix:
phpDoc.parseError — broken phpDoc syntax. Suppressing it leaves the phpDoc permanently unparseable; fix the syntax.argument.templateType — a generic template parameter cannot be inferred from arguments. The template is either misdesigned or redundant; redesign the generic or drop the template parameter.tests/types/* — these files are the library's type contract (TypeAssert / assertType). Suppressing an error here silently invalidates the contract and defeats the purpose of type tests.SHOULD NOT be suppressed — fix unless truly unavoidable:
missingType.* (e.g. missingType.iterableValue, missingType.parameter, missingType.return) — the type is almost always expressible (array<…>, concrete class, list<>, array{…}). Suppress only when the type genuinely cannot be expressed. Exception: missingType.callable for "invoke arbitrary user callbacks" APIs (see "Don't refine bare callable" above) — that one is legitimate.parameter.phpDocType — phpDoc type doesn't match native type. This is almost always a real documentation bug; fix the phpDoc rather than hide it.Target ignores narrowly — never write a blank check. An entry like
# WRONG — blanket suppression hides every future method.notFound in this file
- identifier: method.notFound
path: src/Forms/Controls/SubmitButton.php
silences not only the intended pattern but every future legitimate method.notFound in that file — including real typos and broken refactorings.
Each entry should describe a single concrete phenomenon — a specific message in a specific scope — not "ignore identifier X in file Y". Constraining tools, in order of strength:
message: — regex matching the exact error message. The strongest safeguard: protects against future drift of different errors with the same identifier in the same file. Always include it when the message is reasonably stable.count: — pin the number of occurrences. If a new instance appears (or one disappears), PHPStan reports a mismatch and the developer is forced to look.identifier: — narrows to one error kind.path: src/Foo/Bar.php — single file (preferred when the phenomenon lives in one file)path: src/Foo/Bar/* — wildcard mask for a directory tree (when the same phenomenon recurs across siblings)paths: [...] — explicit list of files (when the phenomenon lives in a handful of unrelated files)Important: count: requires path: (singular), not paths: (plural). With a wildcard mask in path: you still get count: — that's the compact form for "this phenomenon appears N times across this tree". With paths: you sacrifice count: for enumeration; compensate with a tight message: regex.
parameters:
ignoreErrors:
# One phenomenon across a directory tree (wildcard path + count)
- # Latte nodes use new static() by design for extensibility
identifier: new.static
path: src/Bridges/FormsLatte/Nodes/*
count: 6
# One phenomenon across an explicit list of files (paths array + tight message regex)
- # parent::getControl()/getLabel() returns the wider public contract (Html|string|null),
# but BaseControl implementation deterministically returns Html, so chaining is safe.
identifier: method.nonObject
message: '#^Cannot call method \w+\(\) on Nette\\Utils\\Html\|string(\|null)?\.$#'
paths:
- src/Forms/Controls/Checkbox.php
- src/Forms/Controls/CheckboxList.php
- src/Forms/Controls/RadioList.php
# One phenomenon in one file (single path + message + count — strongest form)
- # SubmitButton::getScopeForValidation() walks getParent() which is typed as
# Container|Control; lookupPath() exists on Container at runtime.
identifier: method.notFound
message: '#^Call to an undefined method Nette\\Forms\\Container\|Nette\\Forms\\Control::lookupPath\(\)\.$#'
path: src/Forms/Controls/SubmitButton.php
count: 1
Canonical reference: nette/forms phpstan.neon follows this pattern throughout — use it as the template when refactoring other configs.
Always include a comment explaining why the error is ignored.
vendor/bin/phpstan analyse --generate-baseline
Use only for false positives that are not systematic, or individual cases where fix requires BC break.
parameters:
level: 8
paths:
- src
excludePaths:
- src/compatibility.php
# other files for historical compatibility
ignoreErrors:
# systematic patterns with comments
includes:
- phpstan-baseline.neon
Exclude files for backward compatibility with historical versions (compatibility.php, Latte 2 support, etc.).
Files in tests/types/*.php verify that types in the library are defined correctly.
Purpose:
These tests must always pass and must never be ignored.
Using TypeAssert (from nette/phpstan-rules):
use Nette\PHPStan\Tester\TypeAssert;
TypeAssert::assertTypes(__DIR__ . '/data/types.php');
TypeAssert::assertNoErrors(__DIR__ . '/data/clean.php');
Data file with assertType:
use function PHPStan\Testing\assertType;
assertType('non-empty-string', getcwd());
assertType('string', Normalizer::normalize('foo'));
tools
CRITICAL: Read BEFORE writing or modifying any PHP file. A PostToolUse hook automatically runs nette/coding-standard (ECS) on every PHP file after each Edit or Write. The fixer removes unused `use` statements - so never add `use` statements in a separate edit before the code that references them. Always include `use` imports in the same edit as the referencing code, or add the code first then `use` statements. This skill should be used whenever creating new PHP files, editing existing PHP code, adding methods, refactoring, or fixing bugs in PHP - even for small one-line changes.
development
Install nette/coding-standard globally for PHP code style checking
tools
Invoke when fetching web pages from localhost, debugging PHP errors, or interpreting Tracy output (BlueScreen, Tracy Bar, dump). Read BEFORE running curl or Chrome to any local development PHP URL – with Tracy >= 2.12 and a detected agent, Tracy mirrors BlueScreen, Tracy Bar and dumps as markdown into the JS console for easy machine reading. For Chrome MCP, call list_console_messages() to read Tracy output. Essential when: 500 error, blank page, PHP exception, slow page, N+1 queries, or inspecting variables with dump().
tools
Provides Nette Utils helper classes. Use when working with Arrays, Strings, Image, Finder, FileSystem, Json, Validators, DateTime, Html element builder, Random, Callback, Type, or SmartObject from nette/utils. Do NOT use for Nette Schema, Nette Forms, Nette Database, Latte filters, or DI configuration.