Загрузка
Все статьи

PHP: типизирование насквозь через array, список и null — опыт реального проекта

Строгая типизация в PHP 8.2+ отличная, но реальный код завален mixed и array. Как я вёл постепенную миграцию большого PHP-монолита на строгие типы, какие инструменты использовал для автоматического анализа, и почему array — это худший тип, который можно прописать в сигнатуре метода.

Привет! Меня зовут Артём, я бэкенд-разработчик. Сегодня не про интеграции и 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-инцидентам.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Doctrine ORM + Symfony: N+1 проблема, которая убила производительность

Страница каталога загружалась 8 секунд. В профайлере — 340 SQL-запросов для отображения 20 товаров. Классический N+1 через Doctrine ORM. Проблема усугублялась тем, что часть запросов шла через lazy loading, а часть — через явные вызовы в Twig-шаблонах. Рассказываю, как мы нашли все источники N+1 и сократили количество запросов до 4.

14 мая 2026 10 мин

PHP 8.4: новые фичи, которые реально полезны в продакшене

Каждый релиз PHP приносит новые фичи, но не все из них попадают в реальный код. В PHP 8.4 есть несколько изменений, которые мы сразу внедрили в проект: property hooks упростили геттеры/сеттеры, new MyClass() без скобок, и улучшенный array_find. Рассказываю, что реально пригодилось, а что оказалось маркетингом.

13 мая 2026 8 мин

Сложности с разделами каталога в Битрикс24 через REST API: игра с деревом

Обновляете товар через API, передаете SECTION_ID, но он не появляется в нужной папке. В этой статье я объясняю разницу между простым SECTION_ID и сложным деревом инфоблоков, как правильно выстроить иерархию и почему метод crm.product.update не умеет создавать папки.

5 июля 2026 1 мин