Устаревший проект на PHP встречает первый запуск статического анализатора честно: десятки тысяч сообщений обо всём сразу, от обращений к несуществующим методам до передачи null туда, где ждут объект. Картина пугает, но именно она и есть реальная цена десятилетия слабой типизации, а анализатор лишь показывает счёт. Разберём, как декларации типов в PHP доросли до полноценной системы, что PHPStan проверяет на своих десяти уровнях, сколько пользы приносит максимальный уровень на живом проекте и как поднимать его в коде, которому пятнадцать лет.
Как декларации типов в PHP выросли из скалярных подсказок в полноценную систему
Эволюция шла ступенями пятнадцать лет. Пятая ветка умела подсказки классов и массивов, седьмая принесла скалярные параметры и возвращаемые типы, 7.1 добавила nullable и void, а 7.4 - типизированные свойства, момент, с которого класс перестал быть мешком без формы. Восьмёрка продолжила: объединения и mixed в 8.0, перечисления, readonly-свойства и пересечения в 8.1, readonly-классы и сложные типы в 8.2, типизированные константы классов в 8.3, хуки свойств в 8.4.
declare(strict_types=1);
function double(int $value): int
{
return $value * 2;
}
// Без strict_types тот же вызов молча вернул бы 10
double('5'); // TypeError: строка не станет числом
Отдельно живёт режим strict_types: без него PHP мягко приводит строки к числам и молча прощает лишнее, с ним типы соблюдаются буквально, файл за файлом. Половина легаси-кода страны живёт в мягком режиме, и это первая причина, по которой анализатор видит то, чего не видел рантайм: язык прощал, а проверять перестал.
strict_types действует на файл, а не на проект, и наследство из тысяч файлов живёт в разных режимах. Новые проекты объявляют строгий режим конвенцией кодстайла, а перевод легаси не выполняют механически: поведение с приведением типов меняется, и каждый файл сопровождается прогонами тестов.
Перечисления с версии 8.1 закрывают отдельный класс ошибок: статус заказа перестаёт быть строкой с опечатками и становится типом с конечным набором значений.
enum Status: string
{
case Draft = 'draft';
case Paid = 'paid';
}
function label(Status $status): string
{
return match ($status) {
Status::Draft => 'черновик',
Status::Paid => 'оплачен',
};
}
function normalizeId(int|string $id): string
{
return (string) $id;
}
Объединения, пересечения и mixed закрывают большинство практических ситуаций, оставляя без типизации только самый неудобный случай - смешанные массивы из базы, но о них ниже.
Декларации работают и в рантайме: движок проверяет аргументы на входе и возвращаемое значение на выходе, ошибка типа падает на месте вызова, а не в трёх экранах ниже. В мягком режиме числовые строки из HTTP-запросов молча становятся числами, а всякое прочее честно бросает исключение типа.
Что PHPStan проверяет на десяти уровнях строгости
PHPStan анализирует код без запуска и ранжирует строгость от нулевого уровня до девятого. Нулевой ловит очевидное: несуществующие классы, методы и функции. Средние уровни добавляют согласованность типов, nullability и разбор phpdoc. Девятый включает всё: проверку потоков mixed, обобщённые коллекции, недостижимый код, подписи вызываемых значений и типы возвращаемых значений во всех ветках.
parameters:
level: 9
paths:
- src
- tests
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse --memory-limit=1G
Для экосистем поставляются расширения: отдельные пакеты учат анализатор правилам фреймворков, запросам ORM и ассертам тестовых фреймворков.
Порог входа при этом бережный: установка одной зависимостью, конфиг в четыре строки, и уже нулевой уровень ловит опечатки в именах классов и вызовах методов. На больших кодовых базах работает кэш результатов, поэтому повторные прогоны в конвейере занимают секунды, а первый разогрев - минуты и гигабайты памяти.
Механика кэша оттого и заметна: первый прогон строит карту символов проекта и зависимости между файлами, а дальше пересчитываются только потребители изменённого класса. На проекте в десять тысяч файлов это разница между минутами ожидания и секундами в конвейере.
Расширения берут на себя специфику: пакет для фреймворка понимает контейнер и маршруты, пакет для ORM сверяет запросы с маппингом сущностей, пакет для тестов знает, что ассерты сужают типы, и после assertInstanceOf анализатор видит конкретный класс.
Реальная польза максимального уровня на живом проекте
Выгода высшего уровня не в количестве сообщений, а в классе ловимых ошибок. Анализатор прослеживает путь значения от входа до использования и останавливает там, где null может доехать до метода, ожидающего объект.
public function grandTotal(?Customer $customer): float
{
// PHPStan девятого уровня: $customer может быть null здесь
return $customer->discount() * 0.9;
}
Такая ошибка в рантайме выстреливает на продакшене при первом же клиенте без скидки, а анализатор ловит её до коммита, без теста и без воспроизведения. Второй класс выгоды - рефакторинг: смена сигнатуры или переименование метода мгновенно подсвечивает все места, которые надо поправить, и правка перестаёт быть поиском по строкам. Третий класс - контроль документации: phpdoc с типами массивов и формами объектов перестаёт быть прозой, потому что анализатор сверяет его с кодом при каждом прогоне.
Команды с девятым уровнем в конвейере отмечают и тихую пользу: количество обзоров кода, потраченных на охоту за типами, падает, а споры о том, что вернёт метод при пустом результате, закрываются одной строкой декларации.
Ложные срабатывания лечатся явностью: вместо надежды, что переменная уж точно содержит объект, ставится утверждение, и анализатор продолжает строгий разбор.
assert($customer instanceof Customer);
$total = $customer->discount() * 0.9;
Профиль инцидентов на верхних уровнях меняется: вместо нулей в неожидаемых местах приходят ошибки предметной логики, которую инструменту не видно. В этом и состоит граница: анализатор проверяет форму данных, а смысл вычислений остаётся на совести тестов и людей.
Отдельная метрика для таких команд - типовое покрытие: доля кода с явными типами. Расширения анализатора считают её по всему проекту, число движется медленно, но всегда вверх, пока новые файлы пишутся в строгом режиме.
Почему legacy-код сопротивляется высокому уровню
Старая кодовая база устроена враждебно к строгому анализу: свойства без типов, параметры без подсказок, возвращаемые значения, которые бывают и массивом, и объектом, и null, и false одновременно. Отдельное проклятие - динамические методы и свойства: вызовы по вычисляемому имени, обработчики __call, массивы, изображающие объекты. Для анализатора всё это территория mixed, а mixed на девятом уровне - тревога при каждом обращении.
Численный пример типичен: сто тысяч строк легаси дают от двадцати до пятидесяти тысяч сообщений на максимальном уровне, и среди них честная доля ложных срабатываний на хитрых местах. Команда, увидевшая такой отчёт, обычно предлагает поднять уровень до второго и закрыть вопрос, и в этом решении нет ничего постыдного - затыкание десяти тысяч сообщений руками стоит дороже пользы.
Отдельная строка расходов - обучение: phpdoc с формами массивов и обобщёнными шаблонами требует привычки, а привычка требует пары месяцев код-ревью с комментариями уровня "здесь нужен тип, а не надежда".
Отдельная категория врагов - динамические обращения: вызовы методов по вычисляемому имени и свойства через __get, из-за которых анализатор честно отступает в mixed. Тяжёлые подмены в тестах добавляют в отчёты свои строки, и команды часто начинают с исключения тестов из анализа, а заканчивают их возвращением с расширениями.
Базлайн и постепенное повышение уровня как рабочий путь
Штатный инструмент приручения легаси - базлайн: анализатор записывает текущие ошибки в отдельный файл и дальше проверяет только новый код и правки. Счёт старых проблем замораживается, команда работает как раньше, но каждая новая строка проходит полный контроль.
vendor/bin/phpstan analyse --generate-baseline
vendor/bin/phpstan analyse --memory-limit=1G
Дальше работает лестница уровней: старт с третьего-четвёртого, подъём на один уровень за спринт, базлайн пересоздаётся после каждого подъёма. Правки старых файлов выводят их ошибки из базлайна автоматически, и файл тает вместе с прогрессом команды. Через год-полтора такой график обычно доходит до седьмого-восьмого уровня, и оставшаяся часть базлайна превращается в осмысленный план доработок, а не в стену отчаяния.
/** @phpstan-ignore-next-line смешанный формат легаси, ждёт рефакторинга */
$rate = $legacy->getRate();
Точечные подавления сообщений допустимы при одном условии: у каждого стоит причина и ожидаемое время жизни, иначе Suppress-список вырастает в собственную проблему.
Конвейер при базлайне работает просто: сборка сравнивает свежий отчёт с зафиксированным списком и проваливается только на новых сообщениях. Слияние без новых предупреждений становится вопросом одного статуса проверки, и дисциплина входит в ритм сама.
Расписание подъёма выглядит обычно так: нулевой и первый уровни - за первую неделю, второй и третий - за месяц, дальше по уровню за спринт с пересборкой базлайна и ревью тающих файлов.
Phpdoc как топливо анализатора и его обслуживание
Анализатор читает не только декларации, но и phpdoc, и здесь система раскрывается полностью: типы элементов массивов, формы объектов, обобщённые функции, условные возвращаемые значения.
/**
* @param array<int, Order> $orders
* @param array{id: int, total: float} $row
* @return array<string, float>
*/
public function summarize(array $orders, array $row): array {}
Цена обслуживания честная: сигнатуры с phpdoc требуют поддержки при каждом изменении, а устаревший комментарий врёт хуже отсутствия комментария. Выходит и здесь - дисциплина: документация, которую проверяет машина, живёт дольше прозы, которую никто не читает.
Обобщённые функции описываются шаблонами и читаются анализатором целиком:
/**
* @template T
* @param class-string<T> $class
* @return T
*/
function make(string $class) {}
Проект с полным phpdoc отличается и на ревью: рецензент не спрашивает, что лежит в массиве, а читает форму, и время, ранее уходившее на вопросы, возвращается в работу. Рядом живёт второй анализатор, строже в отдельных местах и с сильным модулем анализа заражения данных для безопасности: экосистемы делятся по вкусам, но инвестиции в phpdoc не теряются, оба инструмента читают одни и те же описания типов.
Правила внедрения PHPStan без сопротивления команды
Порядок, проверенный на чужих шишках:
- первый запуск на нулевом уровне по одному каталогу, не по всему проекту;
- базлайн до любых запретов на правки и до любых обещаний руководству;
- новые файлы сразу под максимальный уровень, без переходного периода;
- подъём на один уровень за спринт с пересозданием базлайна;
- расширения под фреймворк и ORM подключить до первых споров о ложных срабатываниях;
- метрику типового покрытия держать на общем дашборде рядом с покрытием тестами;
- каждое подавление сообщения снабжать причиной и сроком жизни.
Первый пункт снимает страх, второй - сопротивление, третий даёт быструю победу, остальное - вопрос темпа.
Статический анализ не заменяет тесты и не отменяет ревью, он меняет экономику правок: ошибка типа ловится до пуша за секунды, а не после инцидента за часы. Для нового проекта максимальный уровень почти бесплатен, для пятнадцатилетнего легаси - это месяцы подъёма по лестнице, но каждый спринт уменьшает базлайн и делает следующую правку дешевле предыдущей. Команды, дошедшие до верхних уровней, обычно описывают результат одинаково скучно: стало спокойнее. Скучно здесь - комплимент.
Путь наверх одинаков у всех: маленькие правки, честный базлайн и терпение. Скорость подъёма определяется не инструментом, а дисциплиной команды, и это единственная честная цена.