Nette PHPStan Rules

Die PHPStan Rules bringen PHPStan bei, Nette-Code zu verstehen, sodass die statische Analyse präzise Typen ableitet und weniger falsche Meldungen erzeugt.

Installieren Sie einfach die Extension, und PHPStan erkennt zum Beispiel den Typ einer Komponente, wo es zuvor nur einen Fehler sah:

class HomePresenter extends Presenter
{
	protected function createComponentMenu(): MenuControl
	{
		return new MenuControl;
	}

	public function renderDefault(): void
	{
		$menu = $this['menu'];      // PHPStan leitet nun MenuControl ab
		$menu->setActive('home');   // keine Warnung wegen unbekannter Methode
	}
}

Installation

Diese Extension baut auf dem statischen Analysator PHPStan auf, der logische Fehler in Ihrem Code findet, noch bevor Sie ihn ausführen. Wenn Sie ihn noch nicht verwenden, installieren Sie ihn mit Composer:

composer require --dev phpstan/phpstan

Legen Sie eine Konfigurationsdatei phpstan.neon an, die die zu analysierenden Verzeichnisse und die Regelstufe angibt:

parameters:
	paths:
		- app

	level: 8

PHPStan wird dann mit diesem Befehl gestartet:

vendor/bin/phpstan analyse

Eine umfassende Dokumentation finden Sie auf der Website von PHPStan.

Installieren Sie danach die Extension selbst:

composer require --dev nette/phpstan-rules

Voraussetzungen: PHP 8.1 oder höher und PHPStan 2.2+.

Damit PHPStan die Extension verwendet, muss sie aktiviert werden. Installieren Sie entweder phpstan/extension-installer, der das für Sie erledigt, oder ergänzen Sie die Extension von Hand in Ihrer phpstan.neon:

includes:
	- vendor/nette/phpstan-rules/extension.neon

Die meisten Prüfungen funktionieren ohne weitere Einrichtung. Nur der Abschnitt Assets braucht einen kleinen Konfigurationsblock in phpstan.neon (unten beschrieben). Beachten Sie, dass die gesamte auf dieser Seite gezeigte Konfiguration in die phpstan.neon gehört, nicht in die common.neon oder andere DI-Konfigurationsdateien Ihrer Anwendung.

Native PHP-Funktionen

Viele native PHP-Funktionen deklarieren einen Rückgabetyp wie string|false oder array|null, obwohl der Fehlerwert nur unter Bedingungen auftritt, die in modernem Code praktisch nicht vorkommen können: getcwd(), das auf einem gesunden Dateisystem fehlschlägt, json_encode(), das ohne JSON_THROW_ON_ERROR fehlschlägt, preg_split(), das bei einem zur Kompilierzeit konstanten Muster fehlschlägt, und so weiter. Die Extension entfernt die unmöglichen Teile dieser Rückgabetypen, sodass PHPStan nicht mehr verlangt, Fehler zu behandeln, die nicht auftreten können.

Die vollständige Liste steht in extension-php.neon.

Closures zur Typprüfung zur Laufzeit

Ein gängiges PHP-Idiom zur Laufzeitprüfung, ob ein Array Elemente eines deklarierten Typs enthält, verwendet eine typisierte variadische Closure, die mit dem Spread-Operator aufgerufen wird:

/** @param string[] $items */
public function setItems(array $items): void
{
	(function (string ...$items) {})(...$items);
}

PHP erzwingt bei jedem gespreadeten Argument den Typ string und wirft einen TypeError, wenn ein Element kein String ist. Der Rumpf der Closure ist leer, der Ausdruck existiert nur wegen seines Nebeneffekts. PHPStan würde normalerweise expr.resultUnused melden; diese Regel erkennt das Muster und schweigt.

Application

In Presentern beenden Methoden wie redirect(), forward() oder sendJson() den Ablauf, indem sie eine Nette\Application\AbortException werfen. Wenn Sie einen solchen Aufruf in ein try packen und mit einem weit gefassten catch (\Throwable) oder catch (\Exception) abfangen, verschlucken Sie versehentlich die Weiterleitung. Die Extension warnt Sie davor:

try {
	$this->redirect('Homepage:');
} catch (\Throwable $e) {   // Fehler: verschluckt die AbortException
	Debugger::log($e);
}

Die Lösung ist, die Exception erneut zu werfen oder sie vor dem weit gefassten catch in einen eigenen Zweig herauszulösen:

try {
	$this->redirect('Homepage:');
} catch (Nette\Application\AbortException $e) {
	throw $e;
} catch (\Throwable $e) {
	Debugger::log($e);
}

Assets

Konfigurieren Sie in phpstan.neon (nicht in Ihrer Nette-DI-Konfiguration) die Zuordnung von Mapper-IDs zu Mapper-Klassen, damit PHPStan den generischen Typ Asset auf eine konkrete Asset-Klasse einengen kann:

parameters:
	nette:
		assets:
			mapping:
				default: file              # Nette\Assets\FilesystemMapper
				images: file
				vite: vite                 # Nette\Assets\ViteMapper
				custom: App\MyMapper       # beliebiger FQCN

Die Werte file und vite sind Abkürzungen für die eingebauten FilesystemMapper und ViteMapper. Jeder andere Wert gilt als vollqualifizierter Klassenname eines eigenen Mappers.

Nach der Konfiguration:

  • Registry::getMapper('vite') gibt ViteMapper statt Mapper zurück.
  • Registry::getAsset('default:logo.png') gibt ImageAsset zurück. tryGetAsset() gibt ImageAsset|null zurück.
  • FilesystemMapper::getAsset('button.js') und ViteMapper::getAsset() werden auf dieselbe Weise eingeengt.

Component Model

Engt den Rückgabetyp von Container::getComponent() und Container::offsetGet() (also $this['name']) anhand der Factory-Methoden createComponent<Name>() ein, die in derselben Klasse deklariert sind.

class HomePresenter extends Presenter
{
	protected function createComponentMenu(): MenuControl
	{
		return new MenuControl;
	}

	public function renderDefault(): void
	{
		$menu = $this->getComponent('menu');   // MenuControl
		$menu = $this['menu'];                 // MenuControl
	}
}

Existiert keine passende Factory oder ist der Name der Komponente kein zur Kompilierzeit bekannter String, bleibt der Rückgabetyp von getComponent() und $this['name'] unverändert, also das generische IComponent.

Dependency Injection

Properties, die mit dem Attribut #[Nette\DI\Attributes\Inject] gekennzeichnet sind, werden nach dem Erzeugen des Objekts per Dependency Injection gefüllt. PHPStan würde sie deshalb als nicht initialisiert melden; die Extension behandelt sie stattdessen als geschrieben und initialisiert:

class HomePresenter extends Presenter
{
	#[Inject]
	public CartFacade $cart;   // kein Fehler wegen nicht initialisierter Property
}

Forms

Wenn $form->addText('name', …), $form->addSelect(…) und Ähnliches in derselben Funktion oder Methode aufgerufen werden wie der Zugriff auf $form['name'] (oder $form->getComponent('name')), leitet die Extension den Typ des Zugriffs aus dem entsprechenden Aufruf von addXxx() ab:

public function createComponentSignInForm(): Form
{
	$form = new Form;
	$form->addText('username', 'Username');
	$form->addPassword('password', 'Password');

	$form['username'];           // TextInput
	$form['password'];           // TextInput (Password ist eine Unterklasse)
	return $form;
}

Der Zugriff funktioniert auch aus einer anderen Methode als der, in der das Formular erstellt wurde. Wenn Sie es in der Factory createComponentSignInForm() bauen und anderswo auf seine Elemente zugreifen, verfolgt die Extension die Zuweisung zurück zur Factory und findet den passenden Aufruf von addXxx():

public function renderDefault(): void
{
	$form = $this['signInForm'];      // löst createComponentSignInForm() auf
	$form['username'];                // TextInput

	// direkter verketteter Zugriff funktioniert ebenfalls
	$this['signInForm']['username'];  // TextInput
	$this['signInForm-username'];     // TextInput
}

Wird kein passender Aufruf von addXxx() gefunden, fällt die Extension auf die Suche nach der Factory createComponent<Name>() zurück, genau wie die Extension Component Model.

Properties für Event-Handler

Formulare konvertieren die Daten in den Typ, der im Parameter des Callbacks deklariert ist, sei es stdClass, array oder ein eigenes DTO. Ein Callback, dessen Datenparameter enger ist als die deklarierte Union array|object, ist zur Laufzeit also gültig:

$form->onSuccess[] = function (Form $form, MyDto $data): void {
	// …
};

PHPStan würde normalerweise assign.propertyType melden, weil MyDto enger ist als array|object. Die Regel unterdrückt diesen Fehler bei Form::$onSuccess, $onError, $onSubmit, $onRender, Container::$onValidate, SubmitButton::$onClick und $onInvalidClick.

Schema

Engt den Rückgabetyp von Expect::array() anhand des Arguments von der deklarierten Union Structure|Type ein:

Expect::array();                                   // Type
Expect::array(['name' => Expect::string()]);       // Structure (alle Werte sind Schema)
Expect::array(['name' => Expect::string(), 'x']);  // Structure|Type (Schema und Nicht-Schema gemischt)

Wenn das Argument Schema- und Nicht-Schema-Werte mischt, bleibt die deklarierte Union erhalten.

Tester

PHPStan versteht die Typeinengung nach Aufrufen von Tester\Assert. Unterstützte Methoden: null(), notNull(), true(), false(), truthy(), falsey(), same(), notSame(), type().

function process(?User $user): void
{
	Assert::notNull($user);
	$user->getName();          // keine Warnung "called on null"
}

Arrow Functions als void-Callbacks

Die Funktionen test() und Assert::exception() von Tester nehmen Callbacks vom Typ Closure(): void entgegen, üblich ist es aber, Arrow Functions wie fn () => throw new MyException zu übergeben. Eine Arrow Function hat immer einen Rückgabewert, was PHPStan normalerweise als Typkonflikt meldet. Die Regel unterdrückt diesen Fehler bei den folgenden Funktionen und Methoden: test(), testException(), testNoError(), Tester\Assert::exception(), Tester\Assert::throws(), Tester\Assert::error(), Tester\Assert::noError().

Utils

Strings::match() und matchAll(): Bei einem konstanten Muster wird der Rückgabetyp direkt aus dem regulären Ausdruck abgeleitet, also aus seinen Capture-Gruppen (einschließlich benannter und optionaler). Die Flags captureOffset, unmatchedAsNull und bei matchAll() außerdem patternOrder und lazy spiegeln sich in der resultierenden Form wider:

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}>

Bei einem nicht konstanten Muster (und bei der Methode split()) wird die Form nur aus den Flags abgeleitet.

Strings::replace(): Ist der Ersatz ein Callback, wird der Typ seines Parameters $matches aus demselben regulären Ausdruck abgeleitet:

Strings::replace($s, '#(\d+)#', function (array $m) {
	return $m[1];   // $m ist vom Typ array{non-empty-string, decimal-int-string}
});

Einengung des Subjekts nach match(): Innerhalb von if (Strings::match($s, …)) wird auch der durchsuchte String $s anhand des Musters eingeengt, zum Beispiel auf non-empty-string.

Prüfung des Musters: Ein ungültiger regulärer Ausdruck, der an match(), matchAll(), split() oder replace() übergeben wird, wird schon bei der Analyse statt erst zur Laufzeit gemeldet.

Arrays::invoke() und Arrays::invokeMethod() geben ein Array des Rückgabetyps des Callables bzw. der Methode zurück statt des deklarierten array.

Helpers::falseToNull() engt den Rückgabetyp ein, indem es false entfernt und null ergänzt. Aus string|false wird also string|null.

Magische Methoden von Html: $el->setClass(…), $el->addData(…), $el->getHref() und Ähnliche werden ohne @method-Annotationen aufgelöst. setXxx() und addXxx() geben static zurück (Fluent API), getXxx() gibt mixed zurück.