Claude
Skills
Sign in
Back

phpstan-analysis

Included with Lifetime
$97 forever

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.

General

What this skill does


## PHPStan Analysis

### Running PHPStan

```bash
# 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 Levels

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.

- **Level 7**: Union types checked
- **Level 8**: Null checks, strict types (our target)

### nette/phpstan-rules

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:

- **Nette helpers**: `Strings::match()`, `Arrays::invoke()`, `Helpers::falseToNull()`, `Expect::array()`, `Html` magic methods (`setXxx`/`getXxx`/`addXxx`), `Container::getComponent()` and `$this['name']`, Form `$form['name']`
- **Native PHP functions**: `|false` / `|null` removed where unrealistic (`getcwd`, `json_encode`, `preg_*`, intl/GD/DOM/etc.)
- **After `Tester\Assert`**: `notNull()`, `type()`, `true()`, etc. narrow the type
- **Silenced false positives**: arrow fns passed to `test()` / `Assert::exception()`, runtime variadic-closure type validation, Form event-handler callbacks with narrow data parameter

Two 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.

```neon
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.

```neon
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

---

## Error Resolution Strategy

### Resolution Priority

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:

1. **Refactoring** - if an error reveals a design weakness, fix the design first
2. **phpDoc** - if the code is correct but its types are imprecise
3. **`assert()`** - sparingly, only when the type cannot be expressed otherwise
4. **Ignore in `phpstan.neon`** - for systematic or intentional patterns, always with a comment explaining why
5. **Baseline** - last resort, keep minimal

Two hard rules override the ladder at every step:

- **Never silence errors** - a fix must not hide a potential problem (see "Never Silence Errors" below).
- **Never use `@phpstan-ignore` annotations** - keep checker-specific directives out of source code; ignore in `phpstan.neon` instead.

### Create a Plan First

Before making any changes, create a plan:

1. **Group errors by type** (`property.nonObject`, `method.notFound`, `new.static`, etc.)
2. **For each type, choose a resolution** following the priority order above
3. **Justify each decision** with clear reasoning
4. **Present the plan** before implementing

### Refactoring as First Choice

Always ask: **does this error reveal a real design issue?** Examples:

- **Overly broad return types** - method returns `mixed` or `object` but always returns a specific type; narrow the return type
- **Interface too loose** - code calls a method on implementation but not on interface; extend the interface
- **Mixed responsibilities** - class handles too many types; split it
- **Unnecessary dynamic access** - `__get`/`__set` where typed properties would work

The goal is not to "make PHPStan happy" but to use its feedback as a catalyst for better code.

---

## Code Fixes Guidelines

### Never Silence Errors

The code worked before. A fix that hides an error degrades code quality.

```php
// 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).

### Throw Expression Pattern

```php
// Before - fopen can return false
$f = fopen($file, 'r');

// Correct fix
$f = fopen($file, 'r') ?: throw new IOException("Cannot open file $file");
```

### Beware of `/** @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.

### Don't "refine" bare `callable` into `callable(...mixed): mixed`

When 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.
- A normal callback like `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:

```php
// 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.

### Beware of `?:` operator with falsy values

The `?:

Related in General