Nette PHPStan Rules
PHPStan Rules apprennent à PHPStan à comprendre le code Nette, si bien que l'analyse statique déduit des types précis et signale moins de faux positifs.
Il suffit d'installer l'extension et PHPStan reconnaîtra par exemple le type d'un composant là où il ne voyait auparavant qu'une erreur :
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
Cette extension repose sur l'analyseur statique PHPStan, qui détecte les erreurs de logique dans votre code avant même que vous ne l'exécutiez. Si vous ne l'utilisez pas encore, installez-le via Composer :
composer require --dev phpstan/phpstan
Créez un fichier de configuration phpstan.neon indiquant les répertoires à analyser et le niveau des
règles :
parameters:
paths:
- app
level: 8
PHPStan se lance ensuite avec la commande :
vendor/bin/phpstan analyse
Vous trouverez une documentation complète sur le site de PHPStan.
Installez ensuite l'extension elle-même :
composer require --dev nette/phpstan-rules
Prérequis : PHP 8.1 ou supérieur et PHPStan 2.2+.
Pour que PHPStan utilise l'extension, il faut l'activer. Installez soit phpstan/extension-installer, qui s'en charge pour vous, soit ajoutez
l'extension manuellement à votre phpstan.neon :
includes:
- vendor/nette/phpstan-rules/extension.neon
La plupart des contrôles fonctionnent sans réglage supplémentaire. Seule la section Assets a
besoin d'un petit bloc de configuration dans phpstan.neon (décrit ci-dessous). Notez que toute la configuration
présentée sur cette page appartient à phpstan.neon, pas au common.neon de votre application ni aux
autres fichiers de configuration DI de Nette.
Fonctions natives de PHP
De nombreuses fonctions natives de PHP déclarent un type de retour comme string|false ou array|null,
alors même que la valeur d'erreur ne survient que dans des conditions qui, en pratique, ne peuvent pas se produire dans du code
moderne : getcwd() qui échoue sur un système de fichiers sain, json_encode() qui échoue sans
JSON_THROW_ON_ERROR, preg_split() qui échoue sur un motif constant à la compilation, et ainsi de
suite. L'extension retire les parties impossibles de ces types de retour, pour que PHPStan cesse de vous demander de traiter des
erreurs qui ne peuvent pas arriver.
La liste complète est dans extension-php.neon.
Closures de validation de type à l'exécution
Un idiome courant en PHP pour vérifier à l'exécution qu'un tableau contient des éléments du type déclaré utilise une closure variadique typée, appelée avec l'opérateur de décomposition :
/** @param string[] $items */
public function setItems(array $items): void
{
(function (string ...$items) {})(...$items);
}
PHP impose le type string à chaque argument décomposé et lève une TypeError si un élément n'est
pas une chaîne. Le corps de la closure est vide, l'expression n'existe que pour son effet de bord. PHPStan signalerait
normalement expr.resultUnused ; cette règle reconnaît le motif et reste silencieuse.
Application
Dans les presenters, des méthodes comme redirect(), forward() ou sendJson() terminent
l'exécution en levant Nette\Application\AbortException. Si vous enveloppez un tel appel dans un try et
que vous l'attrapez avec un large catch (\Throwable) ou catch (\Exception), vous avalez la redirection
par mégarde. L'extension vous en avertit :
try {
$this->redirect('Homepage:');
} catch (\Throwable $e) { // error: swallows AbortException
Debugger::log($e);
}
La solution consiste à relancer l'exception, ou à la traiter dans une branche distincte avant le catch large :
try {
$this->redirect('Homepage:');
} catch (Nette\Application\AbortException $e) {
throw $e;
} catch (\Throwable $e) {
Debugger::log($e);
}
Assets
Dans phpstan.neon (et non dans votre configuration DI de Nette), configurez la correspondance entre identifiants
de mappers et classes de mappers, afin que PHPStan puisse restreindre le type générique Asset à une classe d'asset
concrète :
parameters:
nette:
assets:
mapping:
default: file # Nette\Assets\FilesystemMapper
images: file
vite: vite # Nette\Assets\ViteMapper
custom: App\MyMapper # any FQCN
Les valeurs file et vite sont des raccourcis pour les FilesystemMapper et
ViteMapper intégrés. Toute autre valeur est traitée comme le nom pleinement qualifié d'une classe de mapper
personnalisé.
Après configuration :
Registry::getMapper('vite')renvoieViteMapperau lieu deMapper.Registry::getAsset('default:logo.png')renvoieImageAsset.tryGetAsset()renvoieImageAsset|null.FilesystemMapper::getAsset('button.js')etViteMapper::getAsset()sont restreints de la même façon.
Component Model
Restreint le type de retour de Container::getComponent() et de Container::offsetGet() (c'est-à-dire
$this['name']) d'après les méthodes fabriques createComponent<Name>() déclarées dans la même
classe.
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this->getComponent('menu'); // MenuControl
$menu = $this['menu']; // MenuControl
}
}
Quand aucune fabrique correspondante n'existe ou que le nom du composant n'est pas une chaîne connue à la compilation, le
type de retour de getComponent() et de $this['name'] reste inchangé, à savoir le générique
IComponent.
Dependency Injection
Les propriétés marquées par l'attribut #[Nette\DI\Attributes\Inject] sont remplies par l'injection de
dépendances après la création de l'objet. PHPStan les signalerait donc comme non initialisées ; l'extension les considère au
contraire comme écrites et initialisées :
class HomePresenter extends Presenter
{
#[Inject]
public CartFacade $cart; // no uninitialized-property error
}
Forms
Quand $form->addText('name', …), $form->addSelect(…) et consorts sont appelés dans la
même fonction ou méthode que l'accès à $form['name'] (ou $form->getComponent('name')),
l'extension déduit le type de l'accès à partir de l'appel addXxx() correspondant :
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;
}
L'accès fonctionne aussi depuis une méthode autre que celle où le formulaire a été créé. Quand vous le construisez dans
la fabrique createComponentSignInForm() et que vous accédez à ses contrôles ailleurs, l'extension remonte
l'affectation jusqu'à la fabrique et y retrouve l'appel addXxx() correspondant :
public function renderDefault(): void
{
$form = $this['signInForm']; // resolves createComponentSignInForm()
$form['username']; // TextInput
// direct chained access works as well
$this['signInForm']['username']; // TextInput
$this['signInForm-username']; // TextInput
}
Si aucun appel addXxx() correspondant n'est trouvé, l'extension se rabat sur la recherche d'une fabrique
createComponent<Name>(), exactement comme l'extension Component Model.
Propriétés pour les gestionnaires d'événements
Les formulaires convertissent les données vers le type déclaré dans le paramètre du callback, que ce soit
stdClass, array ou un DTO à vous. Un callback dont le paramètre de données est plus restrictif que
l'union déclarée array|object est donc valide à l'exécution :
$form->onSuccess[] = function (Form $form, MyDto $data): void {
// …
};
PHPStan signalerait normalement assign.propertyType, parce que MyDto est plus restrictif que
array|object. La règle supprime cette erreur sur Form::$onSuccess, $onError,
$onSubmit, $onRender, Container::$onValidate, SubmitButton::$onClick et
$onInvalidClick.
Schema
Restreint le type de retour de Expect::array() depuis l'union déclarée Structure|Type d'après
l'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)
Quand l'argument mélange des valeurs Schema et non-Schema, l'union déclarée est conservée.
Tester
PHPStan comprend la restriction de type après les appels à Tester\Assert. Méthodes prises en charge :
null(), notNull(), true(), false(), truthy(),
falsey(), same(), notSame(), type().
function process(?User $user): void
{
Assert::notNull($user);
$user->getName(); // no "called on null" warning
}
Fonctions fléchées comme callbacks void
Les fonctions test() et Assert::exception() de Tester acceptent des callbacks typés
Closure(): void, mais il est courant de leur passer des fonctions fléchées comme
fn () => throw new MyException. Une fonction fléchée a toujours une valeur de retour, ce que PHPStan signalerait
normalement comme une incompatibilité de type. La règle supprime cette erreur pour les fonctions et méthodes suivantes :
test(), testException(), testNoError(), Tester\Assert::exception(),
Tester\Assert::throws(), Tester\Assert::error(), Tester\Assert::noError().
Utils
Strings::match() et matchAll() : pour un motif constant, le type de retour est déduit
directement de l'expression régulière, c'est-à-dire de ses groupes de capture (y compris nommés et optionnels). Les drapeaux
captureOffset, unmatchedAsNull, et pour matchAll() également patternOrder et
lazy, se reflètent dans la forme résultante :
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}>
Pour un motif non constant (et pour la méthode split()), la forme est déduite des seuls drapeaux.
Strings::replace() : quand le remplacement est un callback, le type de son paramètre $matches
est déduit de la même expression régulière :
Strings::replace($s, '#(\d+)#', function (array $m) {
return $m[1]; // $m is of type array{non-empty-string, decimal-int-string}
});
Restriction du sujet après match() : à l'intérieur de if (Strings::match($s, …)), la
chaîne recherchée $s est elle aussi restreinte d'après le motif, par exemple en non-empty-string.
Validation du motif : une expression régulière invalide passée à match(), matchAll(),
split() ou replace() est signalée pendant l'analyse au lieu de l'être à l'exécution.
Arrays::invoke() et Arrays::invokeMethod() renvoient un tableau du type de retour du
callable / de la méthode, au lieu du array déclaré.
Helpers::falseToNull() restreint le type de retour en retirant false et en ajoutant
null. Ainsi string|false devient string|null.
Méthodes magiques de Html : $el->setClass(…), $el->addData(…),
$el->getHref() et consorts sont résolues sans annotations @method. setXxx() et
addXxx() renvoient static (API fluide), getXxx() renvoie mixed.