PHPStan — инструкция по применению

PHPStan — инструкция по применению

1. Что такое PHPStan и зачем он нужен

PHPStan — это статический анализатор для PHP. Он читает исходники, не выполняя код, и ловит типовые ошибки ещё до запуска тестов или деплоя.  Кроме PHPStan, есть и другие альтернативные библиотеки, а именно Psalm и Phan. Но PHPStan намного популярнее, у него больше звёзд на Github, больше загрузок и он гораздо более производительный. Особенности, которые стоит выделить:

  1. Гибкая градация строгости. Десять уровней (0 – 10) позволяют приближаться к «идеалу» постепенно, а не рушить Legacy за один вечер. 
  2. Производительность. На Laravel‑проекте в 3 000 классов анализ проходит за две‑три минуты даже на CI‑агентах среднего класса; Phan при той же базе обычно в полтора‑два раза медленнее. 
  3. Качество экосистемы. Готовые расширения под любые популярные фреймворки, в том числе Laravel, Livewire, Eloquent, Doctrine. У Psalm их меньше, Phan почти не развивается.

В итоге PHPStan быстрее внедрить, им проще пользоваться человеку, привыкшему к Laravel‑экосистеме, и главное — он даёт возможность идти «лестницей» от уровня 0 к 10, не ломая старый код.

2. Установка в проект на Laravel

В Laravel добавить PHPStan проще всего через composer:

composer require --dev phpstan/phpstan phpstan/extension-installer
composer require --dev nunomaduro/larastan

larastan — это расширение, которое даёт PHPStan знания о «магии» Eloquent, фасадов, Blade‑директив и прочего.

Минимальный конфигурационный файл phpstan.neon в корне проекта:

includes:
    - vendor/nunomaduro/larastan/extension.neon

parameters:
    level: 9
    paths:
        - app
        - database
    excludePaths:
        analyse:
            - storage/*
            - bootstrap/cache/*
    checkMissingCallableSignature: true
    missingCheckedExceptionInThrows: true
    stubFiles:
        - stubs/ThirdPartyPayment.stub
    treatPhpDocTypesAsCertain: false
    tmpDir: var/cache/phpstan
    parallel:
        maxProcessCount: 8

Запуск анализа проекта:

./vendor/bin/phpstan analyse

Если вы хотите проверить только изменённые файлы, можно использовать следующую команду:

./vendor/bin/phpstan analyse --memory-limit=1G --paths-file=$(git diff --name-only origin/main)

3. Что вы получаете на выходе

После прогонки PHPStan выплюнет таблицу всех найденных проблем. Для Laravel есть ещё формат --error-format=github; ошибки превращаются в аннотации прямо в Pull Request.

Пример вывода:

 ------ -----------------------------------------------------------------
  Line   app/Services/LeadService.php
 ------ -----------------------------------------------------------------
  89     Method App\Services\LeadService::assignUser() should return void
         but returns Illuminate\Database\Eloquent\Model.
 129     Call to an undefined method App\Models\Lead::approove().
 ------ -----------------------------------------------------------------

[ERROR] Found 2 errors

 

Вы сразу видите файл, строку и человеческое описание проблемы.

4. Дополнительные возможности, которые открывает PHPStan

Типизация callable

При разработке сложных проектов, нам в качестве параметров часто приходится передавать тип callable:

final class DealService
{
    /**
     * @param callable(Deal): bool $filter
     * @return list<Deal>
     */
    public function getFiltered(callable $filter): array
    {
        return Deal::all()->filter($filter)->all();
    }
}

Если вы забыли указать, что в $filter приходит Deal, PHPStan (с флагом checkMissingCallableSignature) подскажет: «Пропишите сигнатуру callable». Лямбды становятся самодокументируемыми, а IDE умеет автодополнять поля модели внутри анонимной функции.

Поддержка Generics

В Laravel очень популярен паттерн Repository. Generics решают проблему возвращаемого типа:

/**
 * @template TModel of \Illuminate\Database\Eloquent\Model
 */
final class Repository
{
    /** @var class-string<TModel> */
    private string $model;

    /** @param class-string<TModel> $model */
    public function __construct(string $model)
    {
        $this->model = $model;
    }

    /** @return TModel */
    public function find(int $id)
    {
        return $this->model::query()->findOrFail($id);
    }
}

$leadRepo = new Repository(Lead::class);
/** @var Lead $lead */
$lead = $leadRepo->find(7);   // PHPStan знает, что здесь Lead, а не mixed

 

«Бонусные» типы

PHPStan вводит десятки утилитных псевдотипов. Некоторые из самых полезных:

- non-empty-string — пригодится, когда система хранит обязательный внешний идентификатор клиента.

/** @param non-empty-string $externalId */
function pushToERP(string $externalId): void {}

- class-string<PaymentInterface> — точный тип для фабрик:

interface PaymentInterface
{
    public function charge(int $cents): void;
}

/** @param class-string<PaymentInterface> $driver */
function registerPaymentDriver(string $driver): void {}

- positive-int - полезный, если вы хотите указывать идентификатор записи, который должен быть точно положительный

Тонкая детализация массивов и генераторов

Хотите перестать гадать, что лежит в итераторе?

/**
 * @return Iterator<int, Lead>  # <ключ, значение>
 */
function loadNewLeads(): Iterator
{
    foreach (Lead::where('status', 'new')->cursor() as $lead) {
        yield $lead->id => $lead;
    }
}

Если вместо Lead вы случайно засунете Contact, анализатор мгновенно заметит.

Строгое описание исключений

Параметр missingCheckedExceptionInThrows: true заставляет вас объявлять возможные исключения в PHPDoc. Похожий подход знаком джавистам.

use App\Exceptions\EmailGatewayException;

final class Mailer
{
    /**
     * @throws EmailGatewayException
     */
    public function sendInvoice(Invoice $invoice, User $user): void
    {
        if (!$this->gateway->push($invoice, $user->email)) {
            throw new EmailGatewayException('SMTP error');
        }
    }
}

Если внутрь метода затесалась RuntimeException, но не прописана в @throws, PHPStan поднимет ошибку.

Добавляем поддержку ковариантности в наш проект.

У обычного @template параметр считается инвариантным: Repository<Lead> нельзя подставить туда, где ждут Repository<Model>, хотя Lead наследует Model. Указывая @template-covariant, мы говорим PHPStan: «Я только возвращаю объекты этого типа, но никогда их не принимаю». Тогда экземпляр с более “узким” типом можно безопасно передать в переменную с “широким”.

Главное правило: ковариантный шаблон не должен появляться во входных аргументах, иначе это будет считаться нарушением типов.

Пример №1 — Exporter

namespace App\Export;

use Iterator;

/**
 * @template-covariant TModel of \Illuminate\Database\Eloquent\Model
 */
interface Exporter
{
    /**
     * @return Iterator<int, TModel>
     */
    public function export(): Iterator;
}

Экспортер выдаёт модели наружу, но не принимает их обратно, поэтому ковариантность разрешена.

Конкретная реализация:

namespace App\Export;

use App\Models\Lead;
use Generator;

final class LeadCsvExporter implements Exporter
{
    /**
     * @return Generator<int, Lead>
     */
    public function export(): Generator
    {
        foreach (Lead::cursor() as $lead) {
            yield $lead;  // PHPStan знает, что тут Lead
        }
    }
}

 

Использование:

/** @var Exporter<\Illuminate\Database\Eloquent\Model> $exp */
$exp = new LeadCsvExporter();   // ОК: Lead is Model

Без template-covariant такое присваивание вызвало бы ошибку: типы «слишком конкретные».

Пример №2 — ReadonlyCollection

/**
 * @template-covariant T of object
 * @implements \IteratorAggregate<int, T>
 */
final class ReadonlyCollection implements \IteratorAggregate
{
    /** @var list<T> */
    private array $items;

    /** @param list<T> $items */
    public function __construct(array $items)
    {
        $this->items = $items;
    }

    /** @return \ArrayIterator<int, T> */
    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->items);
    }
}

Коллекция только отдаёт элементы, методов add() или set() нет, поэтому ковариантность безопасна. Теперь:

$leads = new ReadonlyCollection([new Lead(), new Lead()]);
$models = new ReadonlyCollection([new Lead()]);

/** @var ReadonlyCollection<\Illuminate\Database\Eloquent\Model> $anyModels */
$anyModels = $leads;    // Valid thanks to covariance

Пример №3 — Пагинатор отчётов

/**
 * @template-covariant TReport of \App\DTO\Report
 */
interface Paginator
{
    /**
     * @return list<TReport>
     */
    public function page(int $page, int $perPage = 20): array;
}

LeadReportPaginator реализует Paginator<LeadReport>, но мы спокойно передаем его в метод, ожидающий Paginator<Report>.

Как не ошибиться

  1. Проверьте, что параметр ни разу не встречается во входящих аргументах. 
  2. Не вставляйте его в свойства, если эти свойства могут перезаписываться извне. 
  3. Дайте чёткие ограничения (of \BaseClass) — это помогает как разработчикам, так и PHPStan.

 

5. Самое сложное — внедрить в существующий проект

У большинства Legacy‑проектов десятки тысяч строк без type‑hint’ов. «Включить PHPStan на максималках» = получить тысячу ошибок и бросить затею. Поэтому путь один — постепенное внедрение.

1. Ставим уровень 0 и добавляем baseline:

./vendor/bin/phpstan analyse --generate-baseline

Файл phpstan-baseline.neon содержит все текущие ошибки и гасит их. Теперь новый код пишется «с нуля ошибок», а старый постепенно чистится: раз‑два за релиз удаляйте несколько строк из baseline и чините конкретные места.

2. Каждые пару недель поднимаем уровень (level: 2, потом `3` …). Главное правило — билд всегда зелёный.

3. Самое важное - не забываем внедрить CI/CD.

Stub‑файлы — щит от неподконтрольных библиотек

Иногда мы не можем изменить сторонний код: например, SDK банка, который нарушает все мыслимые правила. Решение — stub‑файл.

stubs/ThirdPartyPayment.stub:

<?php declare(strict_types=1);

namespace Bank\SDK;

/**
 * @phpstan-type Currency string  # упрощённый alias
 */
interface Gateway
{
    /**
     * @param non-empty-string $clientId
     * @param positive-int     $amountCents
     * @param Currency         $currency
     *
     * @throws \Bank\SDK\Exception\TransferFailed
     */
    public function transfer(string $clientId, int $amountCents, string $currency): void;
}

В нашем коде PHPStan ориентируется на stub, а не на «грязную» реальную реализацию. Ошибки в stub‑ах нельзя игнорировать, поэтому типы остаются консистентными.

6. Как разобраться в типах и как проводить debug?

Вы настроили уровни, завели generics, включили checkMissingCallableSignature, и вот в консоли появляется сообщение:

Parameter #1 $lead of method App\Services\LeadNotifier::notify()
expects App\Models\Lead, Iterator<int, Lead>&non-empty-string given.

Смотрите на этот гибрид Iterator<int, Lead>&non-empty-string и не понимаете, откуда взялся «non‑empty‑string». В таких случаях помогает интерактивный вывод реального, уже проинференсенного типа прямо в коде.

Как работает \PHPStan\dumpType()

  1. Функция существует только для анализатора; в runtime‑коде её нет. 
  2. PHPStan во время анализа подменяет вызов «виртуальной» функцией и печатает тип переменной или выражения. 
  3. После того как вы всё поняли, вызов удаляют либо оборачивают в if (false), чтобы он не мешалcя в дальнейшем.

Простейший пример

namespace App\Services;

use App\Models\Lead;

final class LeadExporter
{
    /**
     * @return list<Lead>
     */
    public function export(): array
    {
        $leads = Lead::query()
            ->where('status', 'new')
            ->pluck('id')    // ❌ typo: pluck returns Collection<string>
            ->all();

        \PHPStan\dumpType($leads); // We will see: array<int, non-empty-string>

        return $leads; // Now we understand problemm
    }
}

Вы запускаете:

vendor/bin/phpstan analyse app/Services/LeadExporter.php

и в выводе между обычными ошибками появляется строка:

Dump of $leads:
array<int, non-empty-string>

Сразу видно: вместо Lead мы получили non-empty-string, значит перепутали метод ORM‑цепочки.

Полезные трюки и приёмы

1. Сравнить, как тип меняется шаг за шагом  

$result = $query->get();
\PHPStan\dumpType($result);         // Collection<int, Lead>
$result = $result->take(10);
\PHPStan\dumpType($result);         // Collection<int, Lead>
$result = $result->toArray();
\PHPStan\dumpType($result);         // array<int, Lead>

Так легко поймать место, где «слетела» информация о ключах или generic‑параметре.

2. Дебажить сложные generics  

/** @var Repository<Deal> $repo */
$repo = resolve(Repository::class);
$deal = $repo->find(42);
\PHPStan\dumpType($deal); // Ожидаем Deal, убедимся, что не mixed

3. Проверить союзные типы в рантайм‑флагах  

/** @var Lead|false $lead */
$lead = Lead::find($id);
\PHPStan\dumpType($lead);            // Lead|false

4. Использовать в тестах или фикстурах  

Иногда удобно оставить «телеметрику» в тестовом коде:

if (false) {           // Никогда не выполнится
    \PHPStan\dumpType(Lead::factory()->make());
}

Такой код виден только анализатору, продакшн его игнорирует.

Флаг --debug ≠ dumpType, но тоже полезен

CLI‑ключ --debug выводит список файлов, точное время обработки и пиковое потребление памяти. Это помогает понять, какой сервис «тормозит» анализ, но не показывает типы переменных. Для самих типов используйте именно dumpType().

Когда проект подрос, а generics с class-string<...> превратились в цепочки из трёх‑четырёх пересечений, человек уже не держит весь вывод в голове. \PHPStan\dumpType() — быстрый способ спросить у анализатора: «Как ты это видишь?» и за минуту найти ошибку, на которую в противном случае ушёл бы час.

Итоги

PHPStan быстро внедряется в Laravel‑проекты, особенно с Larastan. Он даёт:

  • мгновенную обратную связь о типовых ошибках;
  • поддержку generics, callable‑сигнатур, полезных псевдотипов;
  • возможность документировать исключения как в Java;
  • гибкую лестницу уровней строгости и baseline для Legacy.

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js, Wordpress. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235
Email