Устаревший проект на 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 без сопротивления команды

Порядок, проверенный на чужих шишках:

  1. первый запуск на нулевом уровне по одному каталогу, не по всему проекту;
  2. базлайн до любых запретов на правки и до любых обещаний руководству;
  3. новые файлы сразу под максимальный уровень, без переходного периода;
  4. подъём на один уровень за спринт с пересозданием базлайна;
  5. расширения под фреймворк и ORM подключить до первых споров о ложных срабатываниях;
  6. метрику типового покрытия держать на общем дашборде рядом с покрытием тестами;
  7. каждое подавление сообщения снабжать причиной и сроком жизни.

Первый пункт снимает страх, второй - сопротивление, третий даёт быструю победу, остальное - вопрос темпа.

Статический анализ не заменяет тесты и не отменяет ревью, он меняет экономику правок: ошибка типа ловится до пуша за секунды, а не после инцидента за часы. Для нового проекта максимальный уровень почти бесплатен, для пятнадцатилетнего легаси - это месяцы подъёма по лестнице, но каждый спринт уменьшает базлайн и делает следующую правку дешевле предыдущей. Команды, дошедшие до верхних уровней, обычно описывают результат одинаково скучно: стало спокойнее. Скучно здесь - комплимент.

Путь наверх одинаков у всех: маленькие правки, честный базлайн и терпение. Скорость подъёма определяется не инструментом, а дисциплиной команды, и это единственная честная цена.