Nette PHPStan Rules
PHPStan Rules teach PHPStan to understand Nette code, so static analysis infers precise types and reports fewer false positives.
Just install the extension and PHPStan will, for example, recognize a component's type where it previously saw only an error:
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this['menu']; // PHPStan now infers MenuControl
$menu->setActive('home'); // no unknown method warning
}
}
Installation
This extension builds on the PHPStan static analyzer, which detects logical errors in your code before you even run it. If you don't use it yet, install it via Composer:
composer require --dev phpstan/phpstan
Create a phpstan.neon configuration file specifying the directories to analyze and the rule level:
parameters:
paths:
- app
level: 8
PHPStan is then run with the command:
vendor/bin/phpstan analyse
You can find comprehensive documentation on the PHPStan website.
Then install the extension itself:
composer require --dev nette/phpstan-rules
Requirements: PHP 8.1 or higher and PHPStan 2.1+.
For PHPStan to use the extension, it needs to be activated. Either install phpstan/extension-installer, which does this for you, or add the
extension manually to your phpstan.neon:
includes:
- vendor/nette/phpstan-rules/extension.neon
Most checks work without any further setup. Only the Assets section need a small configuration block
in phpstan.neon (described below). Note that all configuration shown on this page belongs in
phpstan.neon, not in your application's common.neon or other Nette DI configuration files.
Native PHP Functions
Many native PHP functions declare a return type like string|false or array|null, even though the
error value only occurs under conditions that practically cannot happen in modern code: getcwd() failing on a sane
filesystem, json_encode() failing without JSON_THROW_ON_ERROR, preg_split() failing on a
compile-time constant pattern, and so on. The extension removes the impossible parts of these return types, so PHPStan stops
asking you to handle errors that cannot occur.
The full list is in extension-php.neon.
Runtime type validation closures
A common PHP idiom for runtime checking that an array contains items of a declared type uses a typed variadic closure called with the spread operator:
/** @param string[] $items */
public function setItems(array $items): void
{
(function (string ...$items) {})(...$items);
}
PHP enforces the string type on each spread argument and throws TypeError if any item is not a
string. The closure body is empty, the expression exists only for its side effect. PHPStan would normally report
expr.resultUnused; this rule recognises the pattern and stays silent.
Application
In presenters, methods like redirect(), forward() or sendJson() end the run by throwing
Nette\Application\AbortException. If you wrap such a call in a try and catch it with a broad
catch (\Throwable) or catch (\Exception), you accidentally swallow the redirect. The extension warns you
about it:
try {
$this->redirect('Homepage:');
} catch (\Throwable $e) { // error: swallows AbortException
Debugger::log($e);
}
The fix is to rethrow the exception, or to carve it out into a separate branch before the broad catch:
try {
$this->redirect('Homepage:');
} catch (Nette\Application\AbortException $e) {
throw $e;
} catch (\Throwable $e) {
Debugger::log($e);
}
Assets
In phpstan.neon (not in your Nette DI config), configure the mapping of mapper IDs to mapper classes so PHPStan
can narrow the generic Asset type to a concrete asset class:
parameters:
nette:
assets:
mapping:
default: file # Nette\Assets\FilesystemMapper
images: file
vite: vite # Nette\Assets\ViteMapper
custom: App\MyMapper # any FQCN
The values file and vite are shortcuts for the built-in FilesystemMapper and
ViteMapper. Any other value is treated as a fully qualified class name of a custom mapper.
After configuration:
Registry::getMapper('vite')returnsViteMapperinstead ofMapper.Registry::getAsset('default:logo.png')returnsImageAsset.tryGetAsset()returnsImageAsset|null.FilesystemMapper::getAsset('button.js')andViteMapper::getAsset()are narrowed the same way.
Component Model
Narrows the return type of Container::getComponent() and Container::offsetGet() (i.e.
$this['name']) based on createComponent<Name>() factory methods declared on the same class.
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this->getComponent('menu'); // MenuControl
$menu = $this['menu']; // MenuControl
}
}
When no matching factory exists or the component name is not a compile-time string, the return type of
getComponent() and $this['name'] stays unchanged, i.e. the generic IComponent.
Dependency Injection
Properties marked with the #[Nette\DI\Attributes\Inject] attribute are filled by dependency injection after the
object is created. PHPStan would therefore report them as uninitialized; the extension instead treats them as written and
initialized:
class HomePresenter extends Presenter
{
#[Inject]
public CartFacade $cart; // no uninitialized-property error
}
Forms
When $form->addText('name', …), $form->addSelect(…) and similar are called in the same
function or method as the access to $form['name'] (or $form->getComponent('name')), the extension
infers the access type from the corresponding addXxx() call:
public function createComponentSignInForm(): Form
{
$form = new Form;
$form->addText('username', 'Username');
$form->addPassword('password', 'Password');
$form['username']; // TextInput
$form['password']; // TextInput (Password is a subclass)
return $form;
}
Access works from a method other than the one where the form was created. When you build it in the
createComponentSignInForm() factory and access its controls elsewhere, the extension traces the assignment back to
the factory and finds the matching addXxx() call:
public function renderDefault(): void
{
$form = $this['signInForm']; // resolves createComponentSignInForm()
$form['username']; // TextInput
}
Direct chained access without an intermediate variable works as well:
$this['signInForm']['username']; // TextInput
If no matching addXxx() call is found, the extension falls back to createComponent<Name>()
factory lookup, just like the Component Model extension.
Event-handler properties
Forms coerce the data to the type declared in the callback's parameter, be it stdClass, array, or a
custom DTO. So a callback whose data parameter is narrower than the declared array|object union is valid at
runtime:
$form->onSuccess[] = function (Form $form, MyDto $data): void {
// …
};
PHPStan would normally report assign.propertyType because MyDto is narrower than
array|object. The rule suppresses that error on Form::$onSuccess, $onError,
$onSubmit, $onRender, Container::$onValidate, SubmitButton::$onClick, and
$onInvalidClick.
Schema
Narrows the return type of Expect::array() from the declared Structure|Type union based on the
argument:
Expect::array(); // Type
Expect::array(['name' => Expect::string()]); // Structure (all values are Schema)
Expect::array(['name' => Expect::string(), 'x']); // Structure|Type (mixed Schema and non-Schema)
When the argument mixes Schema and non-Schema values, the declared union is kept.
Tester
PHPStan understands type narrowing after Tester\Assert calls. Supported methods: null(),
notNull(), true(), false(), truthy(), falsey(),
same(), notSame(), type().
function process(?User $user): void
{
Assert::notNull($user);
$user->getName(); // no "called on null" warning
}
Arrow functions as void callbacks
Tester's test() and Assert::exception() accept callbacks typed as Closure(): void, but
it is common to pass arrow functions like fn () => throw new MyException. An arrow function always has a return
value, which PHPStan would normally flag as a type mismatch. The rule suppresses that error for the following functions and
methods: test(), testException(), testNoError(), Tester\Assert::exception(),
Tester\Assert::throws(), Tester\Assert::error(), Tester\Assert::noError().
Utils
Strings::match() and matchAll(): for a constant pattern, the return type is inferred directly
from the regular expression, i.e. from its capture groups (including named and optional ones). The flags
captureOffset, unmatchedAsNull, and for matchAll() also patternOrder and
lazy are reflected in the resulting shape:
Strings::match($s, '#(\d+)-(\w+)#'); // array{non-falsy-string, decimal-int-string, non-empty-string}|null
Strings::match($s, '#(?<id>\d+)#'); // array{0: non-empty-string, id: decimal-int-string, 1: decimal-int-string}|null
Strings::matchAll($s, '#(\w+)#'); // list<array{string, non-empty-string}>
For a non-constant pattern (and for the split() method), the shape is inferred from the flags only.
Strings::replace(): when the replacement is a callback, the type of its $matches parameter is
inferred from the same regular expression:
Strings::replace($s, '#(\d+)#', function (array $m) {
return $m[1]; // $m is of type array{non-empty-string, decimal-int-string}
});
Subject narrowing after match(): inside if (Strings::match($s, …)) the searched string
$s is also narrowed based on the pattern, for example to non-empty-string.
Pattern validation: an invalid regular expression passed to match(), matchAll(),
split() or replace() is reported during analysis instead of at runtime.
Arrays::invoke() and Arrays::invokeMethod() return an array of the callable / method
return type instead of the declared array.
Helpers::falseToNull() narrows the return type by removing false and adding null.
Thus string|false becomes string|null.
Html magic methods: $el->setClass(…), $el->addData(…),
$el->getHref() and similar are resolved without @method annotations. setXxx() and
addXxx() return static (fluent API), getXxx() returns mixed.