Nette PHPStan Rules
Правила PHPStan учат PHPStan понимать код Nette, благодаря чему статический анализ выводит точные типы и сообщает о меньшем количестве ложных срабатываний.
Достаточно установить расширение, и PHPStan, например, распознает тип компонента там, где раньше видел только ошибку:
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this['menu']; // PHPStan теперь выводит MenuControl
$menu->setActive('home'); // никакого предупреждения о неизвестном методе
}
}
Установка
Это расширение опирается на статический анализатор PHPStan, который находит логические ошибки в вашем коде ещё до его запуска. Если вы его ещё не используете, установите его через Composer:
composer require --dev phpstan/phpstan
Создайте конфигурационный файл phpstan.neon с указанием каталогов
для анализа и уровня правил:
parameters:
paths:
- app
level: 8
PHPStan затем запускается командой:
vendor/bin/phpstan analyse
Исчерпывающую документацию вы найдёте на сайте PHPStan.
Затем установите само расширение:
composer require --dev nette/phpstan-rules
Требования: PHP 8.1 или новее и PHPStan 2.2+.
Чтобы PHPStan расширение использовал, его нужно включить. Либо
установите phpstan/extension-installer, который
сделает это за вас, либо добавьте расширение в свой phpstan.neon
вручную:
includes:
- vendor/nette/phpstan-rules/extension.neon
Большинство проверок работает без дальнейшей настройки. Только
раздел Assets требует небольшого блока конфигурации в
phpstan.neon (описан ниже). Обратите внимание, что вся конфигурация,
показанная на этой странице, относится к phpstan.neon, а не к
common.neon вашего приложения или другим конфигурационным файлам
Nette DI.
Нативные функции PHP
Многие нативные функции PHP объявляют возвращаемый тип вроде
string|false или array|null, хотя ошибочное значение возникает
только при условиях, которые в современном коде практически
невозможны: getcwd() даёт сбой на вменяемой файловой системе,
json_encode() даёт сбой без JSON_THROW_ON_ERROR, preg_split() даёт сбой
на образце-константе времени компиляции и так далее. Расширение
убирает из этих возвращаемых типов невозможные части, так что PHPStan
перестаёт требовать от вас обрабатывать ошибки, которых не
может быть.
Полный список – в extension-php.neon.
Замыкания для проверки типов во время выполнения
Частая идиома PHP для проверки во время выполнения, что массив содержит элементы объявленного типа, использует типизированное замыкание с переменным числом аргументов, вызываемое с оператором распаковки:
/** @param string[] $items */
public function setItems(array $items): void
{
(function (string ...$items) {})(...$items);
}
PHP требует тип string от каждого распакованного аргумента и
выбрасывает TypeError, если какой-то элемент строкой не является.
Тело замыкания пустое, выражение существует только ради побочного
эффекта. PHPStan обычно сообщил бы expr.resultUnused; это правило
распознаёт такой образец и молчит.
Application
В презентерах методы вроде redirect(), forward() или
sendJson() завершают выполнение выбросом Nette\Application\AbortException.
Если вы обернёте такой вызов в try и перехватите его широким
catch (\Throwable) или catch (\Exception), вы нечаянно проглотите
перенаправление. Расширение вас об этом предупредит:
try {
$this->redirect('Homepage:');
} catch (\Throwable $e) { // ошибка: проглатывает AbortException
Debugger::log($e);
}
Исправление – выбросить исключение заново либо выделить его в отдельную ветку перед широким catch:
try {
$this->redirect('Homepage:');
} catch (Nette\Application\AbortException $e) {
throw $e;
} catch (\Throwable $e) {
Debugger::log($e);
}
Assets
В phpstan.neon (а не в конфигурации Nette DI) настройте соответствие
идентификаторов мапперов классам мапперов, чтобы PHPStan мог сузить
обобщённый тип Asset до конкретного класса ресурса:
parameters:
nette:
assets:
mapping:
default: file # Nette\Assets\FilesystemMapper
images: file
vite: vite # Nette\Assets\ViteMapper
custom: App\MyMapper # любое полное имя класса
Значения file и vite – сокращения для встроенных
FilesystemMapper и ViteMapper. Любое другое значение считается полным
именем класса собственного маппера.
После настройки:
Registry::getMapper('vite')возвращаетViteMapperвместоMapper.Registry::getAsset('default:logo.png')возвращаетImageAsset.tryGetAsset()возвращаетImageAsset|null.FilesystemMapper::getAsset('button.js')иViteMapper::getAsset()сужаются точно так же.
Component Model
Сужает возвращаемый тип Container::getComponent() и Container::offsetGet()
(то есть $this['name']) на основе фабричных методов
createComponent<Name>(), объявленных в том же классе.
class HomePresenter extends Presenter
{
protected function createComponentMenu(): MenuControl
{
return new MenuControl;
}
public function renderDefault(): void
{
$menu = $this->getComponent('menu'); // MenuControl
$menu = $this['menu']; // MenuControl
}
}
Когда подходящей фабрики нет или имя компонента не является строкой
времени компиляции, возвращаемый тип getComponent() и $this['name']
остаётся прежним, то есть обобщённым IComponent.
Dependency Injection
Свойства, помеченные атрибутом #[Nette\DI\Attributes\Inject], заполняются
внедрением зависимостей после создания объекта. PHPStan поэтому сообщал
бы о них как о неинициализированных; расширение вместо этого считает
их записанными и инициализированными:
class HomePresenter extends Presenter
{
#[Inject]
public CartFacade $cart; // никакой ошибки о неинициализированном свойстве
}
Forms
Когда $form->addText('name', …), $form->addSelect(…) и им подобные
вызываются в той же функции или методе, что и обращение к $form['name']
(или $form->getComponent('name')), расширение выводит тип обращения из
соответствующего вызова addXxx():
public function createComponentSignInForm(): Form
{
$form = new Form;
$form->addText('username', 'Username');
$form->addPassword('password', 'Password');
$form['username']; // TextInput
$form['password']; // TextInput (Password - подкласс)
return $form;
}
Обращение работает и из метода, отличного от того, где форма была
создана. Когда вы строите её в фабрике createComponentSignInForm(), а к её
элементам обращаетесь в другом месте, расширение прослеживает
присваивание обратно до фабрики и находит подходящий вызов
addXxx():
public function renderDefault(): void
{
$form = $this['signInForm']; // разрешает createComponentSignInForm()
$form['username']; // TextInput
// прямое обращение по цепочке тоже работает
$this['signInForm']['username']; // TextInput
$this['signInForm-username']; // TextInput
}
Если подходящий вызов addXxx() не найден, расширение откатывается
к поиску фабрики createComponent<Name>(), как и расширение Component Model.
Свойства-обработчики событий
Формы приводят данные к типу, объявленному в параметре callback'а, будь то
stdClass, array или собственный DTO. Так что callback, у которого
параметр данных уже объявленного объединения array|object, во время
выполнения корректен:
$form->onSuccess[] = function (Form $form, MyDto $data): void {
// …
};
PHPStan обычно сообщил бы assign.propertyType, потому что MyDto уже,
чем array|object. Правило подавляет эту ошибку у Form::$onSuccess,
$onError, $onSubmit, $onRender, Container::$onValidate,
SubmitButton::$onClick и $onInvalidClick.
Schema
Сужает возвращаемый тип Expect::array() из объявленного объединения
Structure|Type на основе аргумента:
Expect::array(); // Type
Expect::array(['name' => Expect::string()]); // Structure (все значения - Schema)
Expect::array(['name' => Expect::string(), 'x']); // Structure|Type (смесь Schema и не-Schema)
Когда аргумент смешивает значения Schema и не-Schema, объявленное объединение сохраняется.
Tester
PHPStan понимает сужение типов после вызовов Tester\Assert.
Поддерживаемые методы: null(), notNull(), true(), false(),
truthy(), falsey(), same(), notSame(), type().
function process(?User $user): void
{
Assert::notNull($user);
$user->getName(); // никакого предупреждения "вызвано у null"
}
Стрелочные функции как callback'и void
Функции test() и Assert::exception() из Tester принимают callback'и с типом
Closure(): void, но обычно им передают стрелочные функции вроде
fn () => throw new MyException. У стрелочной функции всегда есть
возвращаемое значение, что PHPStan обычно отметил бы как несоответствие
типов. Правило подавляет эту ошибку для следующих функций и методов:
test(), testException(), testNoError(), Tester\Assert::exception(),
Tester\Assert::throws(), Tester\Assert::error(), Tester\Assert::noError().
Utils
Strings::match() и matchAll(): для образца-константы
возвращаемый тип выводится прямо из регулярного выражения, то есть из
его групп захвата (включая именованные и необязательные). Флаги
captureOffset, unmatchedAsNull, а для matchAll() ещё и patternOrder и
lazy, отражаются в получающейся форме:
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}>
Для образца, не являющегося константой (и для метода split()),
форма выводится только из флагов.
Strings::replace(): когда заменой служит callback, тип его параметра
$matches выводится из того же регулярного выражения:
Strings::replace($s, '#(\d+)#', function (array $m) {
return $m[1]; // $m имеет тип array{non-empty-string, decimal-int-string}
});
Сужение строки после match(): внутри if (Strings::match($s, …))
искомая строка $s тоже сужается по образцу, например до
non-empty-string.
Проверка образца: некорректное регулярное выражение, переданное
в match(), matchAll(), split() или replace(), обнаруживается
при анализе, а не во время выполнения.
Arrays::invoke() и Arrays::invokeMethod() возвращают массив
возвращаемого типа callable или метода вместо объявленного array.
Helpers::falseToNull() сужает возвращаемый тип, убирая false и
добавляя null. Так string|false становится string|null.
Магические методы Html: $el->setClass(…),
$el->addData(…), $el->getHref() и им подобные разрешаются без
аннотаций @method. setXxx() и addXxx() возвращают static
(текучий API), getXxx() возвращает mixed.