MidТеория9 min

PHPStan

Статический анализ, уровни 0-10, конфигурация, расширения, PHPDoc для дженериков

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;
}

Проверь себя

5 из 11

Что делает phpstan-strict-rules?

Что такое baseline в PHPStan?

Как правильно типизировать массив пользователей для PHPStan?

Какой уровень PHPStan рекомендуется для нового проекта?

Что делает PHPStan на level 0?