Что такое универсальная коллекция в 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 (заказы), которые знают бизнес-правила: «не может быть двух активных заказов одновременно», «умеет считать общую сумму» и т.д.
Собираем всё вместе
Теперь посмотрим, как это работает в реальной системе:
<?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 — и ни один «ком грязи» не попадёт в основную ветку.
Почему стоит потратить время на правильный подход
Знаете, что я часто слышу от разработчиков?
«Да ладно, и так сойдёт. Дедлайн горит, напишу как попало, потом отрефакторим.»
А потом это «потом» никогда не наступает. И каждый новый разработчик в команде, каждый новый баг, каждая новая фича — это всё новые и новые грабли, на которые вы будете наступать снова и снова.
Что вы получаете, потратив 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% потребностей. Ждать «настоящих» дженериков — это как ждать идеального момента, чтобы начать бегать. Лучше начните сегодня.
И не забывайте жить.