<? phpukraine СПІВБЕСІДИ
Пошук по платформі
CORE PHP · MIDDLE ЧАСТО ПИТАЮТЬ

Що дає PHPStan на різних рівнях і як вводити його в легасі?

Рівні 0-10 вмикають правила порціями: 0-3 ловлять неіснуючі символи й неузгоджені return-типи, 5 перевіряє типи аргументів, 6 вимагає типізації, 8 забороняє звертатися до методів на nullable, 9-10 закривають mixed. У легасі це вводять baseline: заморозити наявні помилки у phpstan-baseline.neon, поставити аналіз у CI на червоне для нового коду і піднімати рівень по одному.

У нас 400 тисяч рядків без типів. З якого рівня PHPStan починати і що робити з тисячами помилок?
Чим рівень 8 відрізняється від 9 і чому після переходу на 8 посипалися помилки про null?
Що таке baseline і чому його не можна перегенеровувати на кожен пуш?
PHPStan лається `Method returns iterable but should return array` - як це лікують дженеріками в docblock?
PHPStan static analysis baseline generics phpdoc legacy nullable

Рівні 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
}
Що рівні - це набори правил, а не «строгість у відсотках»: кандидат називає межу 5 (типи аргументів), 6 (missing typehints), 8 (nullable) і 9/10 (explicit і implicit mixed).
Що baseline - це згенерований файл з `ignoreErrors` і `count`, який підключають через `includes:`, а не спосіб «вимкнути перевірки».
Що новий код має писатися одразу під цільовий рівень, а baseline лише фіксує борг: якщо його перегенеровувати автоматично, аналіз перестає щось ловити.
Що дженеріки в PHPStan живуть у docblock (`@template`, `@extends`, `@param class-string<T>`) і що PHP їх у рантаймі не бачить - це контракт лише для аналізатора та IDE.
Що рівень 8 лікують звуженням типу (early return, `assert()`, локальна змінна), а не `/** @var */` над кожним рядком.
Одразу ставити `level: max` на легасі, отримати 12 тисяч помилок і викинути інструмент через тиждень.
Додати baseline і на цьому зупинитися: рівень ніколи не росте, файл живе роками, а половина помилок у ньому вже неактуальна.
Перегенеровувати baseline у CI-скрипті або в pre-commit хуку: тоді свіжі помилки заморожуються автоматично й аналіз стає декорацією.
Глушити рівень 8 через `@phpstan-ignore-next-line` або `/** @var User $user */` замість того, щоб додати перевірку на null, - помилка зникає з виводу, але `Call to a member function on null` у продакшені лишається.
Писати `@return Collection` без параметрів типу і дивуватися, що PHPStan не бачить типу елементів: без `Collection<int, User>` він знає лише про сам клас.
Вважати, що PHPStan замінює тести: він не запускає код і не бачить помилок логіки, лише невідповідність типів і символів.
ПОРАДА

Скажіть, як саме ви робили б впровадження: «взяти рівень, на якому помилок сотні, а не тисячі, згенерувати baseline, зробити CI червоним для будь-якої нової помилки, а далі піднімати рівень по одному й на кожному кроці розбирати різницю руками». Плюс одна фраза про baseline: «це облік боргу, а не його прощення» - вона запамʼятовується.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

`--generate-baseline` записує кожну наявну помилку як запис `ignoreErrors` з регуляркою, шляхом і кількістю. Файл комітять у git, а перегенерацію роблять руками. Нова помилка того ж типу перевищить `count` і зʼявиться у звіті; полагоджена, навпаки, дасть повідомлення про незбіг завдяки `reportUnmatchedIgnoredErrors`.