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') renvoie ViteMapper au lieu de Mapper.
  • Registry::getAsset('default:logo.png') renvoie ImageAsset. tryGetAsset() renvoie ImageAsset|null.
  • FilesystemMapper::getAsset('button.js') et ViteMapper::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.