Привет! Меня зовут Артём, я бэкенд-разработчик. Сегодня не про интеграции и API, а про внутреннюю кухню PHP — типизацию. Я работаю с PHP больше семи лет, и переход с PHP 5.6 на PHP 8.2 был для меня личным переломным моментом. Особенно painful была миграция старого монолита с 300K строк кода, где типы были указаны только в docblock-комментариях, а в сигнатурах методов царил mixed.
В этой статье я расскажу, как мы внедряли строгую типизацию истребительно, но без остановки разработки, и какие грабли обнаружили на этом пути.
Почему array — это антипаттерн
В PHP тип array в сигнатуре метода не даёт никакой информации о структуре данных. Массив может содержать что угодно: список строк, ассоциативный массив с вложенными объектами, или вообще пустой набор. Когда вы видите function process(array $data): array, вы понимаете только одно — «тут что-то передаётся и что-то возвращается, но что именно — загадка».
В нашем монолите было полно методов типа:
function getOrderInfo($orderId): array
{
return [
'id' => $orderId,
'status' => 'active',
'items' => [...],
'total' => 1500.50,
'customer' => [
'name' => 'Иван',
'email' => 'ivan@example.com'
]
];
}
Вызывающий код не знает, какие ключи есть в массиве, какие из них обязательны, а какие могут отсутствовать. IDE может подсказать что-то по docblock, но docblock не проверяется в рантайме. Я видел баги, когда кто-то обращался к ключу 'total', а метод вернул 'amount' — и PHP спокойно проглотил это, вернув null.
Стратегия миграции: сверху вниз
Мы не стали переписывать весь монолит разом — это было бы самоубийством. Вместо этого мы выбрали стратегию «сверху вниз»: начали с публичных API-контроллеров и постепенно шли вглубь к сервисному слою.
Шаг 1: DTO для входных и выходных данных
Первым делом мы создали DTO (Data Transfer Objects) для всех API-эндпоинтов. Вместо:
// Before
function createOrder(Request $request): array
{
$data = $request->all();
// ...
Стало:
// After
function createOrder(CreateOrderRequest $request): OrderResponse
{
$dto = CreateOrderDto::fromRequest($request);
$order = $this->orderService->create($dto);
return OrderResponse::fromEntity($order);
}
DTO — это readonly-классы с чётко определёнными типизированными свойствами. В PHP 8.2 это выглядит максимально лаконично:
readonly class CreateOrderDto
{
public function __construct(
public string $customerId,
public array $itemIds,
public ?string $comment,
public OrderPriority $priority,
) {}
public static function fromRequest(Request $request): self
{
return new self(
customerId: $request->string('customer_id')->toString(),
itemIds: $request->input('item_ids', []),
comment: $request->input('comment'),
priority: OrderPriority::from($request->input('priority', 'normal')),
);
}
}
Шаг 2: Enum-ы вместо магических строк
Огромное количество багов в нашем монолите было связано с магическими строками: 'active', 'pending', 'cancelled' — они разбросаны по всему коду, и опечатка в строке не вызывает ошибки. PHP 8.1 ввёл enum, и мы мигрировали все статусные поля на backed enum:
enum OrderStatus: string
{
case Active = 'active';
case Pending = 'pending';
case Cancelled = 'cancelled';
case Completed = 'completed';
public function canTransitionTo(self $target): bool
{
return match($this) {
self::Pending => in_array($target, [self::Active, self::Cancelled], true),
self::Active => in_array($target, [self::Completed, self::Cancelled], true),
default => false,
};
}
}
Теперь опечатка в статусе вызывает TypeError на уровне类型 системы, а не тихий баг в бизнес-логике. Метод canTransitionTo — пример инкапсуляции доменной логики прямо внутри enum, что делает код более читаемым и предсказуемым.
Шаг 3: Nullable-типы и null-безопасность
Самый частый источник NullPointerException в PHP — необязательные поля, которые кто-то забыл проверить. В PHP 8.0 появились nullable-типы (?string) и nullsafe-оператор (?->), но они не решают проблему полностью.
Мой подход: по умолчанию все поля в DTO — non-nullable. Если поле может отсутствовать, это должно быть явным решением, а не случайностью:
// BAD: null is silently accepted
function getCustomerName(array $data): string
{
return $data['name'] ?? 'Unknown';
}
// GOOD: null is explicit and handled
function getCustomerName(array $data): string
{
if (!isset($data['name']) || $data['name'] === '') {
throw new InvalidArgumentException('Customer name is required');
}
return $data['name'];
}
Инструменты, которые реально помогли
Мы использовали три инструмента для автоматизации миграции. PHPStan на уровне 8 (max) помог найти все места, где типы не сходятся. Rector автоматизировал рутинные преобразования — замену docblock-типов на нативные, миграцию на constructor property promotion, добавление readonly. Psalm использовался параллельно с PHPStan для cross-check, потому что иногда они находили разные проблемы.
Rector-конфиг для миграции выглядел примерно так:
// rector.php
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withRules([
ConstFetchToConstClassRector::class,
PublicClassMethodToPublicFunctionRector::class,
])
->withPhpSets(php82: true);
Полная миграция заняла четыре месяца, и даже после неё мы оставили declare(strict_types=1) только в новых файлах, постепенно добавляя его в старые при каждом рефакторинге. Спешка здесь — прямой путь к production-инцидентам.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье