Универсальные коллекции в PHP: DDD, Чистая архитектура и Generics без боли

«Время — самый ценный ресурс. И оно не безразмерное. Каждый час, потраченный сегодня на правильную архитектуру — это больше свободного времени на семью, хобби и любимую книгу завтра».

Если вы работали с C++ или Java, вы привыкли к шаблонам (templates/generics). Одна функция, один класс — работает с любыми типами, при этом компилятор строго следит за типами. Красиво, лаконично, без дублирования.

В PHP такой возможности «из коробки» нет. И многие разработчики, пытаясь решить эту проблему, скатываются в одну из двух крайностей:

  1. Плодят десятки одинаковых классовUserCollection, OrderCollection, ProductCollection, каждый из которых содержит одинаковый код.
  2. Используют «голые» массивыarray, User[], теряя всю типобезопасность и превращая код в «большой ком грязи».

Оба подхода — путь в никуда. Но есть третий путь, о котором мы и поговорим.

Что такое универсальная коллекция в PHP

Универсальная коллекция — это класс, который может работать с элементами любого типа, сохраняя при этом строгую типизацию. В PHP это реализуется через PHPDoc-аннотации @template и проверяется статическими анализаторами PHPStan или Psalm.

Базовый пример

<?php

declare(strict_types=1);

namespace App\Infrastructure\Collection;

/**
 * Универсальная типизированная коллекция.
 *
 * @template T
 */
final class TypedCollection implements \IteratorAggregate, \Countable
{
    /** @var array<int, T> */
    private array $items = [];

    /**
     * @param T $item
     */
    public function add(mixed $item): void
    {
        $this->items[] = $item;
    }

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

    public function count(): int
    {
        return count($this->items);
    }

    /**
     * @return array<int, T>
     */
    public function toArray(): array
    {
        return $this->items;
    }
}

Обратите внимание на @template T. Это указание для PHPStan: «данный класс параметризован типом T». Теперь, когда мы используем эту коллекцию, мы можем указать конкретный тип:

/** @var TypedCollection<User> $users */
$users = new TypedCollection();
$users->add(new User());   // ✅ OK
$users->add(new Order());  // ❌ PHPStan выдаст ошибку

Где применять: два слоя — две философии

В рамках DDD и Чистой архитектуры важно понимать: универсальные коллекции и доменные коллекции — это разные вещи, и живут они в разных слоях.

Application / Infrastructure слой: универсальные коллекции

Здесь мы используем «общие» коллекции для передачи данных между слоями. Они не содержат бизнес-логики — это просто контейнеры.

Пример: Use Case возвращает коллекцию пользователей

<?php

declare(strict_types=1);

namespace App\Application\UseCase\User;

use App\Domain\User\User;
use App\Domain\User\UserRepositoryInterface;
use App\Infrastructure\Collection\TypedCollection;

final class GetAllActiveUsers
{
    public function __construct(
        private UserRepositoryInterface $repository,
    ) {}

    /**
     * @return TypedCollection<User>
     */
    public function __invoke(): TypedCollection
    {
        $users = $this->repository->findActive();

        $collection = new TypedCollection();
        foreach ($users as $user) {
            $collection->add($user);
        }

        return $collection;
    }
}

В Presenter или контроллере мы чётко знаем, с чем работаем:

/** @var TypedCollection<User> $users */
$users = ($this->getAllActiveUsers)();

foreach ($users as $user) {
    // IDE подсказывает, что $user — это User
    // PHPStan проверяет, что мы обращаемся только к методам User
    echo $user->getEmail();
}

Domain слой: First-Class Collections

А вот в Domain слое философия иная. Здесь коллекции — это полноценные объекты предметной области со своим поведением и бизнес-правилами. Их называют «First-Class Collections» (термин из книги Мартина Фаулера).

Пример: коллекция заказов с бизнес-логикой

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Common\Exception\DomainException;
use App\Domain\Common\ValueObject\Money;

final class Orders
{
    /** @var Order[] */
    private array $items = [];

    public function add(Order $order): void
    {
        $this->guardAgainstDuplicateActive($order);
        $this->items[] = $order;
    }

    public function hasActiveOrder(): bool
    {
        foreach ($this->items as $order) {
            if ($order->isActive()) {
                return true;
            }
        }
        return false;
    }

    public function getTotalAmount(): Money
    {
        $total = Money::zero();
        foreach ($this->items as $order) {
            $total = $total->add($order->getAmount());
        }
        return $total;
    }

    private function guardAgainstDuplicateActive(Order $newOrder): void
    {
        if ($newOrder->isActive() && $this->hasActiveOrder()) {
            throw new DomainException(
                'Нельзя создать новый заказ: уже есть активный'
            );
        }
    }

    /** @return Order[] */
    public function toArray(): array
    {
        return $this->items;
    }
}

Здесь коллекция — это не просто «мешок для объектов». Это Orders (заказы), которые знают бизнес-правила: «не может быть двух активных заказов одновременно», «умеет считать общую сумму» и т.д.

💡 Правило большого пальца: если коллекция содержит только CRUD-операции — она техническая. Если в ней есть бизнес-правила — она доменная. Не смешивайте эти роли.

Собираем всё вместе

Теперь посмотрим, как это работает в реальной системе:

<?php

declare(strict_types=1);

namespace App\Domain\Customer;

use App\Domain\Order\Order;
use App\Domain\Order\Orders;

final class Customer
{
    private Orders $orders;

    public function __construct(
        private CustomerId $id,
        private string $email,
    ) {
        $this->orders = new Orders();
    }

    public function placeOrder(Order $order): void
    {
        // Бизнес-правило инкапсулировано в Orders
        $this->orders->add($order);
    }

    public function hasOutdatedUnpaidOrders(): bool
    {
        return $this->orders->hasActiveOrder();
    }

    public function getOrders(): Orders
    {
        return $this->orders;
    }
}

А в Application слое мы можем использовать универсальную коллекцию для передачи списка клиентов:

<?php

declare(strict_types=1);

namespace App\Application\UseCase\Customer;

use App\Domain\Customer\Customer;
use App\Infrastructure\Collection\TypedCollection;

final class GetCustomersWithDebt
{
    /**
     * @return TypedCollection<Customer>
     */
    public function __invoke(): TypedCollection
    {
        // ...
    }
}

Почему без PHPStan/Psalm это превратится в «ком грязи»

Теперь главный вопрос: а зачем всё это, если PHP всё равно не проверяет типы в runtime?

Отвечаю прямо: без статического анализатора все эти @template, @param и @return — просто комментарии. Красивые, но бесполезные. PHP их проигнорирует, и вы получите те же грабли, от которых пытаетесь уйти.

Что будет без PHPStan

/** @var TypedCollection<User> $users */
$users = new TypedCollection();

$users->add(new User());
$users->add('строка вместо пользователя'); // 💥 Ошибка только в runtime!
$users->add(null); // 💥 Ещё один сюрприз
$users->add(new Order()); // 💥 А это мы обнаружим, когда упадёт продакшн

Что будет с PHPStan на уровне 8 (максимальная строгость)

$users->add('строка');
// ❌ PHPStan: Parameter #1 $item of method TypedCollection<User>::add()
// expects User, string given.

$users->add(null);
// ❌ PHPStan: Parameter #1 $item of method TypedCollection<User>::add()
// expects User, null given.

Ошибка обнаруживается в момент написания кода, а не в момент его выполнения. Это принципиально меняет качество разработки.

Настройка PHPStan — 5 минут, которые экономят часы

Создайте файл phpstan.neon в корне проекта:

parameters:
    level: 8
    paths:
        - src
    checkMissingIterableValueType: true
    checkGenericClassInNonGenericObjectType: true

Запустите:

vendor/bin/phpstan analyse

Всё. Теперь каждый коммит проверяется на типобезопасность. Интегрируйте в CI/CD — и ни один «ком грязи» не попадёт в основную ветку.

⚠️ Начинайте с уровня 5–6. Если сразу взять уровень 8, можно утонуть в ошибках. Поднимайте планку постепенно — по мере рефакторинга старого кода.

Почему стоит потратить время на правильный подход

Знаете, что я часто слышу от разработчиков?

«Да ладно, и так сойдёт. Дедлайн горит, напишу как попало, потом отрефакторим.»

А потом это «потом» никогда не наступает. И каждый новый разработчик в команде, каждый новый баг, каждая новая фича — это всё новые и новые грабли, на которые вы будете наступать снова и снова.

Что вы получаете, потратив 2–3 часа на настройку

  • IDE, которая реально помогает. Автодополнение работает не «на слово», а на основе реальных типов. Вы перестанете гадать, что возвращает метод.
  • Рефакторинг без страха. Изменили сигнатуру метода — PHPStan сразу покажет все места, где нужно поправить код. Не в runtime, не на проде, а прямо сейчас.
  • Меньше багов в продакшене. Целый класс ошибок (передал не тот тип, забыл проверить на null) просто исчезает.
  • Документация, которая не врёт. PHPDoc — это и есть документация, которая всегда актуальна, потому что анализатор её проверяет.
  • Спокойный сон. Вы знаете, что ваш код типобезопасен. Это не паранойя, это профессионализм.

А теперь главное

Время — самый ценный ресурс. И оно не безразмерное.

Каждый час, потраченный сегодня на правильную архитектуру, на настройку статического анализатора, на написание понятного кода — это:

  • Меньше ночных звонков «продакшн упал, разбирайся»
  • Меньше выходных, потраченных на разбор багов
  • Меньше седых волос на голове
  • Больше времени на семью, на детей, на жену/мужа
  • Больше времени на хобби — будь то велосипед, гитара, фотография или что угодно ещё
  • Больше времени на любимую книгу, которую вы всё откладываете «на потом»
🧘 Потратьте время сейчас.
Настройте PHPStan. Изучите @template. Разберитесь с DDD. И через год вы скажете себе «спасибо» — потому что ваш код будет работать, а у вас будет свободное время на жизнь.

А жизнь, поверьте, гораздо интереснее, чем дебаг в пятницу вечером.

Краткая памятка

Слой Тип коллекции Пример Нужен @template?
Infrastructure / Application Универсальная TypedCollection<User> ✅ Да
Domain First-Class Orders, Users, Products ❌ Нет (конкретный тип)
Передача между слоями DTO-коллекция UserDtoCollection ✅ Да

Обязательный набор инструментов

  • PHPStan / Psalm — обязательно, уровень 8 (или постепенно до 8)
  • PHPDoc с @template, @param, @return — обязательно
  • CI/CD с запуском анализатора — обязательно

Частые вопросы

❓ А как же производительность? Не медленнее ли это, чем «голый» массив?

→ В runtime разницы практически нет — обёртка над массивом стоит копейки. А вот выигрыш в виде отсутствия багов и ускорения разработки перекрывает любые микросекунды. К тому же, если коллекция действительно в горячем пути — её всегда можно заменить на массив, не трогая остальной код.

❓ А зачем First-Class Collections, если есть Doctrine ArrayCollection?

ArrayCollection — это техническая коллекция, она не знает о бизнес-правилах. Если вам нужно проверить «не может быть двух активных заказов одновременно» — это логика домена, и она должна жить в доменном классе, а не в сервисе или контроллере.

❓ Можно ли использовать @template в методах, а не только в классах?

→ Да! Это называется «generic methods». Например: /** @template T */ function first(T ...$items): T. PHPStan и Psalm это отлично понимают.

❓ А что если я использую Laravel Collection или Symfony Collection?

→ Они уже размечены через @template и отлично работают со статическими анализаторами. Просто используйте их с правильной типизацией: /** @var Collection<int, User> */.

❓ PHPStan ругается на new $className() — что делать?

→ Используйте class-string<T> в параметрах. Это специальный тип PHPStan, который говорит: «строка, содержащая имя класса типа T». Тогда new $className() будет корректно типизирован как T.

❓ А в PHP когда-нибудь появятся настоящие дженерики?

→ RFC есть, обсуждения идут, но пока без конкретных сроков. А вот PHPStan и Psalm работают уже сейчас и закрывают 95% потребностей. Ждать «настоящих» дженериков — это как ждать идеального момента, чтобы начать бегать. Лучше начните сегодня.

P.S. Если вы дочитали до конца — вы уже на шаг впереди большинства. Напишите мне удобным способом — поделитесь, как у вас обстоят дела с PHPStan в проектах. 🎁
🧭 Пишите код, который работает на вас.
И не забывайте жить.

Обсуждение

💬 Есть вопросы? Пишите в Telegram-канал или на ping@ambrion.dev