Рівні PHPStan нумерують набори правил, а не градації суворості. Кожен наступний додає перевірки до попереднього: 0-2 шукають символи, яких не існує, і валідують самі docblock, 3 звіряє return-типи й типи властивостей, 4 знаходить мертвий код на кшталт завжди хибного instanceof. Для легасі перший болючий стрибок стається на рівні 5, де починають перевірятися типи аргументів у викликах. Далі 6 вимагає, щоб типи взагалі були (включно з параметрами array), 7 розбирає union-типи, 8 забороняє звертатися до методів на nullable, а 9 і 10 закривають mixed: спершу явний, потім, з версії 2.0, ще й неявний, тобто той, що виникає, коли типу немає ніде.
На рівні 8 PHPStan знає, що метод повертає ?User, і вимагає перевірки перед ->email. На 9 планка інша: значення типу mixed (скажімо, результат json_decode() без прапорців або елемент масиву з невідомого джерела) не можна ні викликати, ні розіменувати, ні передати в типізований параметр. Лишається прокинути його далі як mixed або звузити через is_string(), instanceof, assert(). Різниця між цими двома рівнями добре показує, що інструмент робить насправді: він змушує писати валідацію на межах застосунку, бо задовольнити його інакше нічим.
У легасі все впровадження впирається в одне рішення: з якою кількістю помилок ви готові жити в перший день. Тому аналіз запускають послідовно на рівнях 0, 1, 2 і зупиняються там, де вивід ще читається (сотні рядків, а не тисячі). На цьому рівні генерують phpstan-baseline.neon, підключають його через includes: у phpstan.neon, і CI одразу стає червоним на будь-якій новій помилці. Борг зафіксовано, регресії зупинено. Далі рівень піднімають по одному: підняли, побачили різницю, частину полагодили руками, решту дописали в baseline окремим комітом. Перегенерацію baseline при цьому ніколи не автоматизують: хук чи CI-скрипт, який робить --generate-baseline перед перевіркою, перетворює весь механізм на театр.
Дженеріки в docblock часто й стають головною причиною ставити PHPStan. PHP не має параметрів типу, тому @template T of object, @param class-string<T> $class, @return list<T> існують лише для аналізатора та IDE, зате дають повноцінне виведення типів: findBy(User::class, ...) у прикладі повертає list<User>, і помилку в наступному рядку буде спіймано без запуску коду. Успадкування генерик-класів описують через @extends Repository<User>, колекції через Collection<int, User>, структури-масиви через array shapes на кшталт array{id: int, email: string}. З рівня 6 голий array уже вважається неповним типом, тож саме там команди зазвичай вперше масово переписують docblock. А оскільки в рантаймі це коментарі, вхідні дані все одно доводиться валідувати кодом.
Про межі інструмента краще сказати самому, бо на співбесіді це питання приходить наступним. PHPStan не виконує код: він не бачить помилок у логіці, не знає, що ваш SQL повертає інші стовпці, і майже безсилий там, де тип народжується з рядка: контейнер із $container->get('service'), магія __get, масиви-конфіги. Частину цього закривають розширення (Larastan для Eloquent і фасадів, phpstan-doctrine, phpstan-phpunit), решту доводиться описувати в stubs. І окремо про level: max на легасі: його краще не писати взагалі. Це псевдонім найвищого рівня в поточній версії, тому чергове оновлення пакета одного дня зробить білд червоним без жодної зміни у вашому коді.
declare(strict_types=1);
/**
* Дженерік-репозиторій: T звʼязує аргумент class-string і тип повернення.
*
* @template T of object
*/
final class Repository
{
/**
* @param class-string<T> $class
* @param array<string, scalar> $criteria
* @return list<T> список, а не array<int, T>
*/
public function findBy(string $class, array $criteria): array
{
return []; // тіло не важливе для прикладу
}
/** @return T|null null тут чесний, не «про всяк випадок» */
public function findOne(string $class, int $id): ?object
{
return null;
}
}
final class User
{
public function __construct(public readonly string $email) {}
}
$repo = new Repository();
// PHPStan бачить list<User>: помилку в назві поля спіймає рівень 5
$users = $repo->findBy(User::class, ['status' => 'active']);
echo $users[0]->email;
$one = $repo->findOne(User::class, 42);
// Рівень 8: тут була б помилка «on User|null», тому звужуємо явно
if ($one === null) {
throw new RuntimeException('Користувача не знайдено');
}
echo $one->email;
// Рівень 9: з mixed нічого не можна робити без перевірки типу
/** @param mixed $raw */
function toEmail(mixed $raw): string
{
if (! is_string($raw)) {
throw new InvalidArgumentException('Очікували рядок');
}
return strtolower($raw); // тут уже string, не mixed
}
Скажіть, як саме ви робили б впровадження: «взяти рівень, на якому помилок сотні, а не тисячі, згенерувати baseline, зробити CI червоним для будь-якої нової помилки, а далі піднімати рівень по одному й на кожному кроці розбирати різницю руками». Плюс одна фраза про baseline: «це облік боргу, а не його прощення» - вона запамʼятовується.