---
title: "PHPStan 2.3: стрибок продуктивності, покращені generics і виявлення невикористаних змінних"
url: https://laravelukraine.com/blog/phpstan-23-stribok-produktivnosti-pokrashheni-generics-i-viiavlennia-nevikoristanix-zminnix
date: 2026-10-06
source: https://phpstan.org/blog/phpstan-2-3-leap-in-performance
---

# PHPStan 2.3: стрибок продуктивності, покращені generics і виявлення невикористаних змінних

Минуло понад чотири місяці та 16 патч-релізів з часу [попереднього мінорного релізу PHPStan](https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys). Тож настав час наступного: вийшов [PHPStan 2.3.0](https://github.com/phpstan/phpstan/releases/tag/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× швидший, ніж десять місяців тому.

## Покращення кешу результатів

Це лише про «холодні» запуски, коли [кешу результатів](https://phpstan.org/user-guide/result-cache) немає або він застарів. PHPStan не завжди аналізує всі файли проєкту: [з березня 2020 року](https://phpstan.org/blog/from-minutes-to-seconds-massive-performance-gains-in-phpstan) він запам’ятовує, які помилки були в яких файлах, перевіряє, що змінилося, і повторно аналізує лише змінені файли та ті, що від них залежать.

У повсякденній роботі користувач рідко чекає повного запуску - усе залежить від кількості змінених файлів. Ось запуски з «теплим» кешем на 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 для розширень](https://phpstan.org/developing-extensions/result-cache-meta-extensions#tracking-dependencies-on-files): кастомні розширення можуть описувати, які файли і коли треба інвалідувати, замість скидання всього кешу через будь-яку дрібницю.

Кеш результатів тепер коректно працює і в monorepo-сетапах, що використовують репозиторії `"type": "path"` у `composer.json` ([документація Composer](https://getcomposer.org/doc/04-schema.md#repositories)), та з [scanDirectories і scanFiles](https://phpstan.org/user-guide/discovering-symbols#third-party-code-outside-of-composer-dependencies) для файлів, що часто змінюються.

Кеш результатів варто зберігати й відновлювати в CI для набагато швидших пайплайнів - обов’язково [налаштуйте це](https://phpstan.org/user-guide/result-cache#setup-in-continuous-integration).

## Generics: більше ніякого узагальнення скалярів і багатопрохідний вивід для `new Foo()`

PHPStan підтримує generics [майже сім років](https://phpstan.org/blog/phpstan-0-12-released). Під час розробки доводилося робити складні вибори, а іноді - просто вгадувати, «як воно має працювати». Частина цих рішень виявилася хибною.

Наприклад, функція, що повертає той самий тип, який приймає:

```php
/**
 * @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](https://en.wikipedia.org/wiki/Bottom_type)) - строго кажучи, додати в таку колекцію нічого не можна, що формально правильно, але марно в реальному світі.

Рішення запропонував Арно Ле Блан (Arnaud Le Blanc), який допоміг із понад 50 pull request для початкової реалізації generics у 2019 році: [8 березня 2022 року](https://github.com/phpstan/phpstan/issues/6732#issuecomment-1062029088) він описав його в 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](https://phpstan.org/blog/what-is-bleeding-edge).

Двонапрямне звуження типів та пов’язану роботу **замовив і зробив можливими [Sovereign Tech Fund](https://www.sovereign.tech/)**.

## Виявлення невикористаних змінних

Невикористані змінні - клас помилок, який досі випадав із поля зору PHPStan. Вони можуть бути серйозними: присвоєне, але не використане значення часто означає друкарську помилку або забуту передачу значення кудись - наприклад, обчислений TTL, який так і не передали в `Cache::save()`.

PHPStan розрізняє п’ять сценаріїв:

- змінну присвоєно, але ніколи не прочитано - [`variable.unused`](https://phpstan.org/error-identifiers/variable.unused);
- присвоєне значення не читається до кінця тіла функції (змінну могли читати раніше, вище за присвоєння) - [`assign.unused`](https://phpstan.org/error-identifiers/assign.unused);
- присвоєне значення перезаписується іншим присвоєнням до того, як його прочитали - [`assign.overwritten`](https://phpstan.org/error-identifiers/assign.overwritten);
- змінній присвоюється значення, яке вона вже має - [`assign.redundant`](https://phpstan.org/error-identifiers/assign.redundant);
- змінну присвоєно й нібито використано, але значення нікуди не потрапляє - [`assign.unusedFlow`](https://phpstan.org/error-identifiers/assign.unusedFlow). Ідея навіяна [графом потоку даних у Psalm](https://psalm.dev/articles/better-unused-variable-detection).

Список не вичерпний. Окрім звичайних присвоєнь на кшталт `$foo = ...`, PHPStan перевіряє ключі масивів, які ніколи не читаються або одразу перезаписуються, результати `++` і `--`, ключі та значення `foreach`, змінні в `catch`, змінні `use` у замиканнях, а також параметри функцій і приватних методів.

Приклад `variable.unused`:

```php
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` - лічильник, що збільшується, але ніде не використовується:

```php
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](https://github.com/phpstan/phpstan/releases/tag/2.3.0), а оригінальна стаття доступна в [блозі PHPStan](https://phpstan.org/blog/phpstan-2-3-leap-in-performance).
