1. Что такое PHPStan и зачем он нужен
PHPStan — это статический анализатор для PHP. Он читает исходники, не выполняя код, и ловит типовые ошибки ещё до запуска тестов или деплоя. Кроме PHPStan, есть и другие альтернативные библиотеки, а именно Psalm и Phan. Но PHPStan намного популярнее, у него больше звёзд на Github, больше загрузок и он гораздо более производительный. Особенности, которые стоит выделить:
- Гибкая градация строгости. Десять уровней (0 – 10) позволяют приближаться к «идеалу» постепенно, а не рушить Legacy за один вечер.
- Производительность. На Laravel‑проекте в 3 000 классов анализ проходит за две‑три минуты даже на CI‑агентах среднего класса; Phan при той же базе обычно в полтора‑два раза медленнее.
- Качество экосистемы. Готовые расширения под любые популярные фреймворки, в том числе 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>.
Как не ошибиться
- Проверьте, что параметр ни разу не встречается во входящих аргументах.
- Не вставляйте его в свойства, если эти свойства могут перезаписываться извне.
- Дайте чёткие ограничения (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()
- Функция существует только для анализатора; в runtime‑коде её нет.
- PHPStan во время анализа подменяет вызов «виртуальной» функцией и печатает тип переменной или выражения.
- После того как вы всё поняли, вызов удаляют либо оборачивают в 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.