Минуло понад чотири місяці та 16 патч-релізів з часу попереднього мінорного релізу PHPStan. Тож настав час наступного: вийшов PHPStan 2.3.0. Головні теми релізу: різке прискорення аналізу, розумніший кеш результатів, покращена робота з generics та нові перевірки невикористаних змінних.
Покращення продуктивності
З початку року команда системно працювала над швидкодією PHPStan, і результат дуже помітний. За вимірами автора (5-річний MacBook Pro з M1 Pro), у грудні 2025 року аналіз коду WordPress займав 60 секунд, а сьогодні - лише 8:
| Версія |
Дата релізу |
Turbo вимкнено (с) |
Turbo увімкнено (с) |
Примітка |
| 2.1.33 |
5 грудня 2025 |
60.51 |
- |
Останній реліз до цьогорічних оптимізацій |
| 2.1.34 |
19 січня 2026 |
46.51 |
- |
Кешування reflection-об’єктів і PHPDoc |
| 2.1.38 |
30 січня 2026 |
44.55 |
- |
Дрібні покращення продуктивності |
| 2.1.49 |
16 квітня 2026 |
34.49 |
- |
Подальші оптимізації |
| 2.2.6 |
26 липня 2026 |
32.02 |
24.68 |
Перший реліз із Turbo |
| 2.2.10 |
30 серпня 2026 |
29.94 |
22.20 |
Forking замість spawning воркерів |
| 2.2.13 |
3 вересня 2026 |
23.06 |
14.12 |
Завжди з OPcache, прибрано runtime-перевірки |
| 2.3.0 |
1 жовтня 2026 |
19.47 |
7.93 |
Значно більше класів «затінено» через Turbo |
Начебто все просто: PHPStan прибирав роботу, яку не потрібно виконувати взагалі, яку не потрібно повторювати або яку можна зробити швидше. А в PHPStan 2.2.6 наприкінці липня з’явилося нове - PHPStan Turbo, необов’язкове розширення, що пришвидшує окремі частини PHPStan завдяки їх перереалізації на C++. Воно також вмикає інші техніки оптимізації: forking дочірніх процесів замість їх spawning (зазвичай це неможливо при запуску з PHAR-файлу) та OPcache optimizer pass, який прибирає runtime-перевірки типів у викликах PHPStan самого до себе. Про Turbo автор обіцяє окрему статтю найближчими днями.
Незважаючи на те, що розширення необов’язкове, більшість користувачів уже мають його ввімкненим автоматично й не мусять керувати ним вручну. Файли .so/.dll постачаються всередині Composer-пакета phpstan/phpstan, PHPStan сам обирає потрібний для поточного PHP-середовища та перезапускається з увімкненим розширенням через параметри командного рядка php -d.
У першому релізі Turbo PHPStan став на 23% швидшим порівняно з базовою лінією, а тепер Turbo пришвидшує його майже на 60%. Підсумок: сьогоднішній PHPStan до 7,5× швидший, ніж десять місяців тому.
Покращення кешу результатів
Це лише про «холодні» запуски, коли кешу результатів немає або він застарів. PHPStan не завжди аналізує всі файли проєкту: з березня 2020 року він запам’ятовує, які помилки були в яких файлах, перевіряє, що змінилося, і повторно аналізує лише змінені файли та ті, що від них залежать.
У повсякденній роботі користувач рідко чекає повного запуску - усе залежить від кількості змінених файлів. Ось запуски з «теплим» кешем на Drupal (великий проєкт: повний запуск - 22 секунди проти 8 для WordPress):
| Файлів |
Частка |
Час (с) |
Примітка |
| 0 |
0% |
1.58 |
Нічого не змінилося з минулого запуску |
| 10 |
0.1% |
2.84 |
|
| 1 124 |
10% |
6.21 |
|
| 2 771 |
25% |
9.89 |
|
| 5 574 |
50% |
16.10 |
|
| 7 204 |
64% |
19.03 |
|
| 8 956 |
80% |
21.14 |
|
| 11 194 |
100% |
22.08 |
Кеш очищено |
Повторний запуск без змін друкує результат за 1,6 секунди; якщо потрібно переаналізувати 10 файлів - 2,8 секунди; якщо 10% проєкту - 6,2 секунди.
Останні покращення гарантують, що весь кеш результатів скидається лише при оновленні PHPStan або зміні його конфігурації в проєкті. Раніше проєкт повністю переаналізовувався при оновленні будь-якої Composer-залежності або будь-якій зміні Symfony DI-контейнера. Тепер PHPStan відстежує залежності між проаналізованими файлами та встановленими пакетами, тож переаналізується лише необхідний мінімум.
Додатково з’явилися нові інтерфейси та API для розширень: кастомні розширення можуть описувати, які файли і коли треба інвалідувати, замість скидання всього кешу через будь-яку дрібницю.
Кеш результатів тепер коректно працює і в monorepo-сетапах, що використовують репозиторії "type": "path" у composer.json (документація Composer), та з scanDirectories і scanFiles для файлів, що часто змінюються.
Кеш результатів варто зберігати й відновлювати в CI для набагато швидших пайплайнів - обов’язково налаштуйте це.
Generics: більше ніякого узагальнення скалярів і багатопрохідний вивід для new Foo()
PHPStan підтримує generics майже сім років. Під час розробки доводилося робити складні вибори, а іноді - просто вгадувати, «як воно має працювати». Частина цих рішень виявилася хибною.
Наприклад, функція, що повертає той самий тип, який приймає:
/**
* @template T
* @param T $a
* @return T
*/
function doFoo($a)
{
// ...
}
Який тип слід вивести для doFoo(1): int чи 1? Єдиної правильної відповіді немає - кожен варіант має свої компроміси. PHPStan обрав узагальнення скалярів, тобто 1 ставало int. Ретроспективно це було помилкою, і користувачі роками повідомляли про багато проблем, бо хотіли зберегти точний скалярний тип. Але виправити це, не зламавши один важливий сценарій, було неможливо.
Який тип у $c = new Collection([1, 2, 3])? Якщо Collection<1|2|3>, то не вийде викликати $c->add(4). Якщо Collection<int>, то не вийде передати її в параметр із типом Collection<positive-int>, хоча це має бути валідно. А для порожньої new Collection([]) тип @template T стає never (порожній bottom type) - строго кажучи, додати в таку колекцію нічого не можна, що формально правильно, але марно в реальному світі.
Рішення запропонував Арно Ле Блан (Arnaud Le Blanc), який допоміг із понад 50 pull request для початкової реалізації generics у 2019 році: 8 березня 2022 року він описав його в issue «Bidirectional type narrowing» - звідси й назва ідеї. Автору знадобилося понад чотири роки, щоб зібрати сміливість і фінансування для коректної та ефективної реалізації.
Суть рішення: код навколо new Collection(...) аналізується кілька разів, PHPStan спостерігає, як об’єкт використовується і куди передається, і на цій основі обмежує остаточний виведений тип @template T. Приклади з інтерактивного віджета статті:
- Передача у функцію. Якщо
$ints = new Collection([]) передається в takeInts(Collection<int>), PHPStan виводить Collection<int>, а виклик takeStrings($ints) коректно дає помилку: Collection<int> замість Collection<string>.
- Точні скаляри. Для
$ids = new Collection([1, 2, 3]), який згодом передається в saveIds(Collection<positive-int>), виводиться Collection<int<1, max>>, а $ids->add(-1) повідомляє помилку, що очікується int<1, max>.
- Виклики методів. Для
$items = new Collection() із викликами add(1) та add('one') виводиться Collection<int> (якщо колекція далі передається в takeInts), а add('one') - помилка.
- Спільний вивід. Для
swap(Box<T> $a, Box<T> $b) з new Box(1) та new Box('one') обидві змінні матимуть тип Box<1|'one'>.
Перший наївний прототип сповільнював PHPStan приблизно на 30%. Після переосмислення стало зрозуміло, що переаналізовувати все не потрібно - лише рядки, на які безпосередньо впливають generic-об’єкти з нерозв’язаними template-типами. Вплив цієї можливості на продуктивність - від 1 до 3% залежно від інтенсивності використання generics, що з запасом перекривається оптимізаціями в цьому ж релізі.
Завдяки цій роботі PHPStan більше не потребує узагальнення скалярних типів у контексті generics. Реалізація закрила 17 пов’язаних issues і незліченні дублікати. Щоб скористатися покращеннями, увімкніть bleeding edge.
Двонапрямне звуження типів та пов’язану роботу замовив і зробив можливими Sovereign Tech Fund.
Виявлення невикористаних змінних
Невикористані змінні - клас помилок, який досі випадав із поля зору PHPStan. Вони можуть бути серйозними: присвоєне, але не використане значення часто означає друкарську помилку або забуту передачу значення кудись - наприклад, обчислений TTL, який так і не передали в Cache::save().
PHPStan розрізняє п’ять сценаріїв:
Список не вичерпний. Окрім звичайних присвоєнь на кшталт $foo = ..., PHPStan перевіряє ключі масивів, які ніколи не читаються або одразу перезаписуються, результати ++ і --, ключі та значення foreach, змінні в catch, змінні use у замиканнях, а також параметри функцій і приватних методів.
Приклад variable.unused:
function sendWelcomeEmail(Mailer $mailer, User $user): void
{
$subject = 'Welcome to Acme!';
$mailer->send($user->getEmail(), 'Welcome!', 'Glad to have you.');
}
PHPStan повідомить: Variable $subject is never read.
Приклад assign.unusedFlow - лічильник, що збільшується, але ніде не використовується:
function importRows(Importer $importer, array $rows): void
{
$imported = 0;
foreach ($rows as $row) {
$importer->import($row);
$imported = $imported + 1;
}
}
Тут обидва присвоєння $imported (рядки 3 і 6) отримають повідомлення, що значення «only flows into values that are never used».
У джерелі наведено інтерактивний віджет з окремими ідентифікаторами помилок для різних конструкцій:
- Масиви:
array.unusedOffset, array.offsetOverwritten, array.unusedOffsetFlow - ключі масиву, які не читаються, перезаписуються до читання або потрапляють лише в невикористані значення.
- foreach:
foreach.unusedValue, foreach.valueOverwritten, foreach.unusedValueFlow, foreach.unusedKey, foreach.keyOverwritten, foreach.unusedKeyFlow.
- Інкременти й декременти:
preInc.*, postInc.*, preDec.*, postDec.* з варіантами unused, overwritten та unusedFlow.
- catch:
catch.unusedVariableFlow - змінна винятку лише потрапляє у значення, які ніколи не використовуються.
- Замикання:
closure.unusedUse та closure.unusedUseFlow - невикористані змінні в use.
- Функції та методи:
function.unusedParameter, function.unusedParameterFlow, method.unusedParameter, method.unusedParameterFlow - невикористані параметри функцій і приватних методів.
Наприклад, для приватного методу InvoiceMailer::body(Order $order, string $email), де $email не використовується, PHPStan повідомить Method InvoiceMailer::body() has an unused parameter $email. (method.unusedParameter), а для замикання function (string $name) use ($logger, $greeting), де $logger не потрібен, - Anonymous function has an unused use $logger. (closure.unusedUse).
Повний список змін і всі деталі релізу - на сторінці PHPStan 2.3.0 на GitHub, а оригінальна стаття доступна в блозі PHPStan.