Зачем PHPDoc, если в PHP уже есть типы
Native types в PHP — это первая линия защиты. Они проверяются рантаймом: если параметр, return value, property или class constant объявлены с типом, PHP сам бросит TypeError, когда значение не подходит. Это фундамент из статьи Типы и strict_types.
PHPDoc решает другую задачу. Он описывает то, что язык пока не умеет выразить достаточно точно: «массив строк», «список объектов», «массив с конкретными ключами», «коллекция элементов одного типа», «строка, которая является именем класса», «callable с конкретной сигнатурой». Такие комментарии читают IDE, PHPStan, Psalm и генераторы документации. Сам PHP их не исполняет.
DocBlock начинается с /**, а не с обычного /*. Внутри чаще всего встречаются @param, @return, @var, @throws, @template, tool-specific теги вроде @phpstan-type и @psalm-type. Хороший PHPDoc не дублирует очевидное, а добавляет недостающую информацию.
<?php
// Бесполезно: native type уже всё сказал.
/**
* @param string $email
* @return bool
*/
function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
// Полезно: PHP не умеет выразить тип элементов массива.
/**
* @param list<non-empty-string> $emails
* @return list<non-empty-string>
*/
function normalizeEmails(array $emails): array
{
return array_values(array_unique(array_map('strtolower', $emails)));
}Array shapes и типизированные массивы
В PHP массив — это ordered map, как описано в Массивы как ordered map. Native type array говорит только «это массив». Он не говорит, какие там ключи, какие значения и можно ли обращаться к $row['email'].
Для списков и словарей используют generic notation:
/** @param array<int, User> $usersById */
function sendDigest(array $usersById): void
{
foreach ($usersById as $id => $user) {
// $id: int, $user: User
}
}
/** @param list<User> $users */
function renderUsers(array $users): string
{
// list означает последовательные integer keys с 0.
return implode(', ', array_map(fn (User $user) => $user->name, $users));
}Для структурированных данных удобны array shapes. Это особенно полезно на границах: результат json_decode, вход из формы, строка из базы, payload из внешнего API. Рядом по смыслу стоят JSON, сериализация и форматы данных, GET, POST и фильтрация ввода и PDO и подключение к базе.
/**
* @param array{
* id: positive-int,
* email: non-empty-string,
* roles?: list<non-empty-string>
* } $payload
*/
function createUserFromPayload(array $payload): User
{
return new User(
id: $payload['id'],
email: $payload['email'],
roles: $payload['roles'] ?? [],
);
}Здесь roles? означает optional key. Анализатор увидит ошибку, если код обращается к несуществующему ключу без проверки, передаёт строку вместо positive-int или смешивает shape с произвольным array<string, mixed>.
flowchart TD
A[Native types PHP] --> D[Статический анализатор]
B[PHPDoc: array shapes, list<T>, @template] --> D
C[Composer autoload, stubs, config] --> D
D --> E[IDE: подсказки и предупреждения]
D --> F[CI: merge блокируется на новых ошибках]
F --> G[Baseline: старый долг отделен от новых ошибок]
G --> H[Постепенное удаление записей baseline]Generics через @template
В PHP нет native generics: нельзя написать Collection<User> как синтаксис языка. Но PHPStan и Psalm понимают generics в PHPDoc. Это не «магия для документации», а реальная информация для статического анализа.
Простейший generic-контейнер:
<?php
/**
* @template T
*/
final class Box
{
/** @param T $value */
public function __construct(
private mixed $value,
) {
}
/** @return T */
public function value(): mixed
{
return $this->value;
}
}
/** @var Box<User> $box */
$box = new Box(new User('a@example.com'));
$user = $box->value(); // анализатор знает: UserВ native-сигнатуре приходится оставить mixed, потому что язык не знает T. Но PHPDoc связывает тип конструктора и тип value(). Если потом кто-то попытается вызвать у $user метод, которого нет у User, PHPStan или Psalm покажет ошибку до запуска кода.
Generics часто встречаются в коллекциях, репозиториях, фабриках, IteratorAggregate, ArrayObject, class-string<T> и reflection-коде. Это стыкуется с SPL, итераторы и коллекции, Autoloading и PSR-4 и Атрибуты и Reflection.
mixed: честный сигнал, а не мусорная корзина
mixed означает: «тип может быть чем угодно». Это иногда честно: данные только что пришли из JSON, $_POST, внешнего API или легаси-слоя. Проблема начинается, когда mixed протекает внутрь доменной логики.
function handleWebhook(mixed $payload): void
{
if (!is_array($payload)) {
throw new InvalidArgumentException('Payload must be an array.');
}
/** @var array{event: non-empty-string, user_id: positive-int} $payload */
dispatchEvent($payload['event'], $payload['user_id']);
}Такой @var допустим только если перед ним есть реальная проверка или нормализация. Иначе это не типизация, а способ заставить анализатор замолчать. Более устойчивый вариант — вынести validation в отдельную функцию, которая возвращает уже понятный shape или DTO-класс.
На строгих уровнях PHPStan начинает жёстко относиться к mixed: сначала отличает implicit mixed из отсутствующих типов от explicit mixed, а на максимальных уровнях ограничивает операции с ним ещё сильнее. Psalm тоже умеет отдельно настраивать отчёты по Mixed* issues. Практическое правило простое: mixed можно оставить на границе системы, но внутри лучше быстро сузить тип.
PHPStan, Psalm, baseline и уровни строгости
Статический анализатор читает код, PHPDoc, Composer autoload, stubs и конфигурацию, но не исполняет обычный request. Он находит несовместимые типы, неверные return values, обращение к nullable-значениям, несуществующие методы, устаревшие PHPDoc и часть dead code. Это не замена PHPUnit из PHPUnit, моки и стабы, а другой слой проверки.
PHPStan использует уровни от 0 до 10: 0 самый мягкий, 10 самый строгий; уровни cumulative. Для нового проекта разумно быстро дойти до высокого уровня. Для легаси-кода обычно начинают ниже, фиксируют шум, затем поднимают планку. Psalm устроен иначе: уровни 1–8, где 1 самый строгий, а 8 самый мягкий.
Baseline — это файл с уже известными ошибками. Он нужен, чтобы включить анализ в большом старом проекте и не блокировать все merge request сразу. Но baseline не должен становиться свалкой. Хорошая практика: commit baseline, не перегенерировать его ради каждой новой ошибки, периодически удалять исправленные записи и держать новые файлы под более строгим правилом. В CI, линтеры и автоматические проверки анализатор обычно запускается рядом с composer validate, composer audit, style checks и тестами.
Где PHPDoc заканчивается
PHPDoc не должен спорить с native type. Если в коде function total(): int, а в DocBlock написано @return string, это не дополнительная точность, а баг. Native type — источник правды для того, что язык умеет выразить. PHPDoc добавляет слой поверх: элементы массива, shape, template-параметры, callable signature, @throws, type aliases.
Когда shape становится слишком большим, лучше подумать о DTO, enum или value object. Массив array{street: string, city: string, zip: string} нормален для маленькой внутренней структуры. Но если вокруг него появляются методы, инварианты и бизнес-смысл, это уже кандидат на класс из Классы, объекты и видимость или Enum в PHP.
Ещё одна граница — vendor-код. Если зависимость плохо описана, не надо править vendor/ руками. Для этого существуют stubs, extensions и локальные type aliases. Это связано с Composer, Packagist и composer.json: зависимости приходят через Composer, а проектная типовая информация должна жить в конфигурации проекта, а не в скачанном пакете.
См. также
- Типы и strict_types — native type declarations,
mixed, union/intersection types и strict mode. - Массивы как ordered map — почему
arrayв PHP требует аккуратного описания ключей и значений. - JSON, сериализация и форматы данных — где появляются
mixedи array shapes после декодирования данных. - Autoloading и PSR-4 — как анализаторы находят классы проекта.
- PSR-1, PSR-12 и стиль кода — где форматирование заканчивается и начинается семантическая проверка.
- CI, линтеры и автоматические проверки — где запускать PHPStan, Psalm и проверку baseline.
- PHPUnit, моки и стабы — чем тесты отличаются от статического анализа.