PHPStan — статический анализ PHP
Что такое статический анализ
Статический анализ — это проверка кода без его выполнения. Анализатор читает исходный код и находит ошибки: несовпадение типов, обращение к несуществующим методам, передачу неверных аргументов, dead code и многое другое.
Зачем нужен статический анализ:
- Находит баги до запуска — ошибки типов, null-reference, несуществующие методы
- Заменяет часть тестов — не нужно писать тесты на проверку типов
- Улучшает дизайн — заставляет писать типизированный, предсказуемый код
- Автоматическая проверка — запускается в CI/CD на каждый коммит
- Документирование — строгие типы служат документацией
PHPStan — самый популярный статический анализатор для PHP. Он анализирует код, используя информацию о типах из: type hints, PHPDoc, анализа потока данных (data flow analysis).
Установка
# Install PHPStan
composer require --dev phpstan/phpstan
# Verify installation
vendor/bin/phpstan --version
# Install useful extensions
composer require --dev phpstan/phpstan-strict-rules
composer require --dev phpstan/phpstan-phpunit
composer require --dev phpstan/extension-installer
# Framework-specific extensions
composer require --dev phpstan/phpstan-symfony # For Symfony
composer require --dev phpstan/phpstan-doctrine # For Doctrine
composer require --dev larastan/larastan # For Laravel
Конфигурация phpstan.neon
PHPStan настраивается через файл phpstan.neon (или phpstan.neon.dist) в корне проекта.
Базовая конфигурация
parameters:
# Analysis level (0-10)
level: 9
# Paths to analyze
paths:
- src
- tests
# Paths to exclude
excludePaths:
- src/DataFixtures
- src/Migrations
- src/Kernel.php
# Memory limit
memoryLimit: 512M
# Treat PHPDoc types as certain (strict mode)
treatPhpDocTypesAsCertain: true
# Report unmatched ignored errors
reportUnmatchedIgnoredErrors: true
# Check missing typehints
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
Продвинутая конфигурация
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
excludePaths:
- src/DataFixtures
- src/Migrations
# Symfony-specific
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
# Doctrine-specific
doctrine:
objectManagerLoader: tests/doctrine-bootstrap.php
# Custom type aliases
typeAliases:
UserId: 'App\ValueObject\UserId'
Money: 'App\ValueObject\Money'
# Parallel processing
parallel:
maximumNumberOfProcesses: 8
# Ignore specific errors
ignoreErrors:
# Ignore by message pattern
- '#Call to an undefined method Doctrine\\ORM\\EntityRepository::findByEmail\(\)#'
# Ignore in specific file
-
message: '#Parameter .+ expects string, mixed given#'
path: src/Legacy/OldService.php
# Ignore with count (expected number of occurrences)
-
message: '#Unsafe usage of new static#'
count: 3
path: src/Entity/BaseEntity.php
Уровни анализа (0-10)
PHPStan имеет 10 уровней строгости. Каждый следующий уровень включает все проверки предыдущих.
Level 0 — базовые проверки
<?php
declare(strict_types=1);
// Level 0 catches:
// - Unknown classes, functions, methods
// - Wrong number of arguments
// - Always-undefined variables
class UserService
{
public function find(): void
{
// ERROR: Class NonExistentClass not found
$obj = new NonExistentClass();
// ERROR: Function non_existent_function not found
non_existent_function();
// ERROR: Method findAll expects 1 argument, 0 given
$this->findAll();
}
public function findAll(int $limit): array
{
return [];
}
}
Level 1-3 — проверка типов
<?php
declare(strict_types=1);
// Level 1: possibly undefined variables
function example(bool $condition): string
{
if ($condition) {
$name = 'John';
}
// Level 1 ERROR: Variable $name might not be defined
return $name;
}
// Level 2: unknown methods on known types
function processUser(User $user): void
{
// Level 2 ERROR: Call to undefined method User::nonExistentMethod()
$user->nonExistentMethod();
}
// Level 3: return types
function getUser(): User
{
// Level 3 ERROR: Function should return User but returns null
return null;
}
Level 4-5 — типы возвращаемых значений
<?php
declare(strict_types=1);
// Level 4: dead code, unreachable branches
function check(int $value): string
{
if ($value > 0) {
return 'positive';
}
if ($value > 0) {
// Level 4 WARNING: This branch is unreachable (dead code)
return 'also positive';
}
return 'non-positive';
}
// Level 5: argument type checking
function greet(string $name): string
{
return "Hello, {$name}!";
}
function caller(): void
{
// Level 5 ERROR: Parameter #1 expects string, int given
greet(42);
}
Level 6-7 — отсутствующие type hints
<?php
declare(strict_types=1);
// Level 6: reports missing typehints
class UserService
{
// Level 6 ERROR: Method has no return type
public function findAll()
{
return [];
}
// Level 6 ERROR: Parameter $name has no type
public function greet($name): string
{
return "Hello, {$name}!";
}
}
// Level 7: union types, partially wrong types
function process(string|int $value): void
{
// Level 7 ERROR: Cannot call strlen() on string|int
// (int doesn't have strlen)
echo strlen($value);
// Correct:
if (is_string($value)) {
echo strlen($value); // OK: narrowed to string
}
}
Level 8-9 — строгая типизация
<?php
declare(strict_types=1);
// Level 8: reports calling methods on nullable types
function getUserName(?User $user): string
{
// Level 8 ERROR: Cannot call getName() on User|null
return $user->getName();
// Correct:
// return $user?->getName() ?? 'Anonymous';
}
// Level 9: mixed type restrictions
function processData(mixed $data): void
{
// Level 9 ERROR: Cannot access property on mixed
echo $data->name;
// Level 9 ERROR: Cannot call method on mixed
$data->process();
// Correct: narrow the type first
if ($data instanceof User) {
echo $data->getName();
}
}
Level 10 — максимальная строгость
<?php
declare(strict_types=1);
// Level 10: strictest checks (introduced in PHPStan 2.x)
// - Even stricter mixed handling
// - Stricter comparison checks
// - All implicit type coercions flagged
Рекомендации по уровням
| Ситуация | Рекомендуемый уровень |
|---|---|
| Legacy-проект | Начать с 0-1, постепенно повышать |
| Новый проект | Сразу level 9 |
| Enterprise | Level 9 + strict-rules |
| Микросервис | Level 9 |
| Open-source пакет | Level 9 |
Запуск PHPStan
# Basic analysis
vendor/bin/phpstan analyse
# Specific level
vendor/bin/phpstan analyse --level 9
# Specific paths
vendor/bin/phpstan analyse src/ tests/
# With memory limit
vendor/bin/phpstan analyse --memory-limit=1G
# Output in different formats
vendor/bin/phpstan analyse --error-format=json
vendor/bin/phpstan analyse --error-format=table
vendor/bin/phpstan analyse --error-format=github # For GitHub Actions
# Clear cache
vendor/bin/phpstan clear-result-cache
# Debug: show what PHPStan thinks about a type
vendor/bin/phpstan analyse --debug
Baseline — для legacy-проектов
Baseline позволяет "заморозить" текущие ошибки и требовать, чтобы новый код был чистым.
# Generate baseline (saves current errors)
vendor/bin/phpstan analyse --generate-baseline
# This creates phpstan-baseline.neon with all current errors
# New errors won't be in baseline and will cause CI failure
Подключение baseline
# phpstan.neon
includes:
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
Стратегия миграции
# Step 1: Generate baseline at current level
vendor/bin/phpstan analyse --level 5 --generate-baseline
# Step 2: Fix new code to level 5 standard
# (baseline ignores existing errors)
# Step 3: Gradually fix baseline errors
# Remove entries from phpstan-baseline.neon as you fix them
# Step 4: When baseline is empty, increase level
# Repeat from Step 1 with level 6
Расширения PHPStan
phpstan-strict-rules
Добавляет дополнительные строгие правила:
composer require --dev phpstan/phpstan-strict-rules
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
Что проверяет:
empty()использование (ненадёжная функция)in_array()без strict mode- Свободная сравнения (
==вместо===) - Неиспользуемые выражения
<?php
declare(strict_types=1);
// Strict rules catches:
// ERROR: Use strict comparison (===) instead of loose (==)
if ($value == '123') {}
// ERROR: Call to in_array() without third parameter (strict)
if (in_array($value, $allowed)) {}
// Fix: in_array($value, $allowed, true)
// ERROR: Use of empty() — unreliable function
if (empty($value)) {}
// Fix: if ($value === '' || $value === null)
phpstan-symfony
composer require --dev phpstan/phpstan-symfony
parameters:
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
consoleApplicationLoader: tests/console-loader.php
Что даёт:
- Правильные типы для
$container->get() - Анализ конфигурации сервисов
- Типы для console commands
- Проверка route параметров
phpstan-doctrine
composer require --dev phpstan/phpstan-doctrine
Что даёт:
- Типы для
EntityRepository->find() - Анализ DQL-запросов
- Проверка маппинга сущностей
- Типы для QueryBuilder
phpstan-phpunit
composer require --dev phpstan/phpstan-phpunit
Что даёт:
- Правильные типы для assertions
- Проверка корректности
createMock() - Анализ data providers
PHPDoc аннотации для PHPStan
PHPStan использует PHPDoc для получения дополнительной информации о типах, которую нельзя выразить через PHP type hints.
Базовые аннотации
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
final class UserService
{
/**
* @param list<User> $users List of users to process
* @return array<string, int> Map of email => count
*/
public function countByEmail(array $users): array
{
$result = [];
foreach ($users as $user) {
$email = $user->getEmail();
$result[$email] = ($result[$email] ?? 0) + 1;
}
return $result;
}
/**
* @param non-empty-string $email
* @return User|null
*/
public function findByEmail(string $email): ?User
{
// PHPStan knows $email is never empty
return null;
}
/**
* @param positive-int $page
* @param int<1, 100> $perPage Between 1 and 100
* @return list<User>
*/
public function paginate(int $page, int $perPage = 20): array
{
return [];
}
}
Дженерики (Generics)
PHP не поддерживает дженерики на уровне языка, но PHPStan понимает их через PHPDoc.
<?php
declare(strict_types=1);
namespace App\Collection;
/**
* Generic typed collection.
*
* @template T
*/
final class TypedCollection
{
/**
* @param list<T> $items
*/
public function __construct(
private array $items = [],
) {}
/**
* @param T $item
*/
public function add(mixed $item): void
{
$this->items[] = $item;
}
/**
* @return T|null
*/
public function first(): mixed
{
return $this->items[0] ?? null;
}
/**
* @return list<T>
*/
public function all(): array
{
return $this->items;
}
/**
* @param callable(T): bool $predicate
* @return self<T>
*/
public function filter(callable $predicate): self
{
return new self(
array_values(array_filter($this->items, $predicate)),
);
}
/**
* @template U
* @param callable(T): U $mapper
* @return self<U>
*/
public function map(callable $mapper): self
{
return new self(array_map($mapper, $this->items));
}
}
Использование:
<?php
declare(strict_types=1);
use App\Collection\TypedCollection;
use App\Entity\User;
/** @var TypedCollection<User> $users */
$users = new TypedCollection();
$users->add(new User(name: 'Alice', email: '[email protected]'));
// PHPStan knows this returns User|null
$first = $users->first();
// PHPStan knows this returns TypedCollection<string>
$emails = $users->map(fn(User $u): string => $u->getEmail());
Типовые алиасы
<?php
declare(strict_types=1);
namespace App;
/**
* @phpstan-type UserData array{
* name: non-empty-string,
* email: non-empty-string,
* age: positive-int,
* roles: list<string>,
* metadata?: array<string, mixed>
* }
*/
final class UserFactory
{
/**
* @param UserData $data
*/
public function create(array $data): User
{
return new User(
name: $data['name'],
email: $data['email'],
);
}
}
Импорт типов
<?php
declare(strict_types=1);
namespace App\Service;
/**
* @phpstan-import-type UserData from \App\UserFactory
*/
final class UserImporter
{
/**
* @param list<UserData> $usersData
* @return list<User>
*/
public function importBatch(array $usersData): array
{
return array_map(
fn(array $data): User => $this->factory->create($data),
$usersData,
);
}
}
Часто используемые PHPStan-типы
<?php
declare(strict_types=1);
/**
* @param non-empty-string $name — string that is never empty
* @param positive-int $id — int > 0
* @param non-negative-int $count — int >= 0
* @param int<0, 100> $percentage — int between 0 and 100
* @param list<string> $tags — sequential array (0, 1, 2...)
* @param array<string, mixed> $meta — associative array
* @param non-empty-list<int> $ids — non-empty sequential array
* @param class-string<User> $class — FQCN string of User or subclass
* @param callable(int): string $fn — callable signature
* @param iterable<User> $users — any iterable of User
* @param \Closure(): void $callback — specifically a Closure
* @param value-of<UserStatus> $status — one of enum values
*/
function example(): void {}
Игнорирование ошибок
Иногда нужно подавить ложные срабатывания или временно пропустить ошибку.
В коде
<?php
declare(strict_types=1);
// Ignore the next line
/** @phpstan-ignore-next-line */
$value = $unsafeOperation();
// Ignore specific error on the same line
$result = $obj->method(); // @phpstan-ignore-line
// PHPStan 2.x: ignore with identifier
$result = $repo->findAll(); // @phpstan-ignore method.notFound
В конфигурации
parameters:
ignoreErrors:
# By message regex
- '#Call to an undefined method [a-zA-Z\\]+Repository::findBy[A-Z]#'
# By message + path
-
message: '#Cannot call method .+ on mixed#'
path: src/Legacy/*
# By message + paths list
-
message: '#has no return type#'
paths:
- src/Legacy/
- src/Generated/
# With expected count
-
message: '#Unsafe usage of new static#'
count: 2
path: src/Entity/BaseEntity.php
Практический пример: настройка для Symfony-проекта
phpstan.neon
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
- phpstan-baseline.neon
parameters:
level: 9
paths:
- src
- tests
excludePaths:
- src/DataFixtures
- src/Migrations
- src/Kernel.php
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
treatPhpDocTypesAsCertain: true
reportUnmatchedIgnoredErrors: true
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
Типичные ошибки и их исправление
<?php
declare(strict_types=1);
// ERROR: Parameter #1 $id of method find() expects int, string given
$user = $repository->find($request->get('id'));
// FIX:
$user = $repository->find((int) $request->get('id'));
// ERROR: Cannot call method getName() on User|null
$user = $repository->find(1);
echo $user->getName();
// FIX:
$user = $repository->find(1);
if ($user === null) {
throw new NotFoundHttpException('User not found');
}
echo $user->getName();
// ERROR: Method returns array<mixed> but should return list<User>
public function findActive(): array
{
return $this->em->createQuery('SELECT u FROM User u WHERE u.active = true')
->getResult();
}
// FIX: add @return annotation
/** @return list<User> */
public function findActive(): array
{
return $this->em->createQuery('SELECT u FROM User u WHERE u.active = true')
->getResult();
}
// ERROR: Property has no type
class Service
{
private $logger; // ERROR
// FIX:
private LoggerInterface $logger;
}