Программирование на PHP в XXI веке: коллекции вместо циклов, функциональное мышление и отделение подготовки данных от сайд-эффектов

Современный PHP (8.x) — это строгая типизация, readonly-свойства, enum, match, «первоклассные» коллбэки (strlen(...) в 8.1+), атрибуты и производительные JIT/OPcache. На таком фоне меняется и стиль кода: всё больше внимания на декларативные конвейеры данных, чистые функции и явные границы сайд-эффектов. Ниже — практическое руководство с примерами.


Почему коллекции лучше «ручных» циклов

Циклы (for, foreach) универсальны, но быстро обрастают условной логикой и мутациями, усложняя чтение и тестирование. Коллекции и функции высшего порядка (map/filter/reduce) дают:

  • Декларативность: читаем «что делаем», а не «как бежим по индексам».

  • Композицию: легко добавлять/переставлять шаги конвейера.

  • Меньше состояний: избегаем промежуточных переменных и счётчиков.

  • Тестируемость: каждый шаг — отдельная чистая трансформация.

Пример: фильтр, проекция и агрегат

До (императивно):

$total=0; foreach ($orders as $order) { if ($order['status']==='paid' && $order['amount'] > 1000) { $total +=$order['amount'] * 0.9; // скидка } } 

После (чистый PHP, без фреймворков):

$paidOver1000=array_filter($orders, fn($o)=> $o['status']==='paid' && $o['amount'] > 1000); $discounted=array_map(fn($o)=> $o['amount'] * 0.9, $paidOver1000); $total=array_reduce($discounted, fn($sum, $a)=> $sum + $a, 0); 

С коллекцией (например, Laravel Collection):

use Illuminate\Support\Collection; /** @var Collection $orders */ $total=$orders ->where('status', 'paid') ->filter(fn($o)=> $o['amount'] > 1000) ->map(fn($o)=> $o['amount'] * 0.9) ->sum(); 

Такой код легче изменять: добавили ещё одну map() — и готово, без риска сломать состояние в середине цикла.


Функциональное программирование в PHP: основные приёмы

Чистые функции и неизменяемые данные

Чистая функция при одинаковом входе возвращает одинаковый выход и не имеет сайд-эффектов (никаких запросов к БД, echo, записи в файлы и т. п.).

final class Money { public function __construct( public readonly int $amountCents, public readonly string $currency, ) {} } function applyDiscount(Money $price, float $rate): Money { return new Money((int)round($price->amountCents * (1 - $rate)), $price->currency); } 

Функции высшего порядка и «первоклассные» коллбэки

В PHP 8.1+ можно получить callable «как значение»:

$len=strlen(...); // callable echo $len('hello'); // 5 

Это удобно для композиции:

$trim=fn(string $s): string=> trim($s); $upper=fn(string $s): string=> mb_strtoupper($s); $format=fn(string $s): string=> $upper($trim($s)); echo $format(" hi "); // "HI" 

Каррирование и частичное применение

$multiply=fn(int $a)=> fn(int $b)=> $a * $b; $double=$multiply(2); echo $double(21); // 42 

Композиция через «пайпы»

В коллекциях часто есть pipe():

$result=collect($users) ->filter(fn($u)=> $u->active) ->pipe(fn($c)=> ['count'=> $c->count(), 'emails'=> $c->pluck('email')->all()]); 

Отделение подготовки данных от сайд-эффектов («Functional Core, Imperative Shell»)

Идея: вся бизнес-логика (валидация, расчёты, трансформации) — чистые функции. Сайд-эффекты (БД, API, e-mail, логирование) — на «краях» приложения, в тонкой оболочке.

Пример: импорт CSV клиентов

Шаг 1. Чистая подготовка данных

final class ClientDTO { public function __construct( public readonly string $email, public readonly string $name, public readonly ?string $phone, ) {} } function parseCsv(string $path): iterable { $h=fopen($path, 'rb'); try { while (($row=fgetcsv($h, 0, ';')) !==false) { yield $row; } } finally { fclose($h); } } function toClientDTO(iterable $rows): iterable { foreach ($rows as [$email, $name, $phone]) { $email=mb_strtolower(trim($email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { continue; // отбрасываем невалидные } yield new ClientDTO($email, trim($name), $phone ? trim($phone) : null); } } // Конвейер подготовки $clients=toClientDTO(parseCsv('/path/clients.csv')); // iterable чистых DTO 

Шаг 2. Императивная оболочка — единая точка сайд-эффектов

function persistClients(iterable $clients, PDO $pdo): int { $count=0; $pdo->beginTransaction(); try { $stmt=$pdo->prepare('INSERT INTO clients(email, name, phone) VALUES(?, ?, ?) ON CONFLICT(email) DO NOTHING'); foreach ($clients as $c) { $stmt->execute([$c->email, $c->name, $c->phone]); $count++; } $pdo->commit(); return $count; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } // Все эффекты — здесь: $pdo=new PDO(/* ... */); $count=persistClients($clients, $pdo); 

Преимущества: чистые функции легко покрываются тестами; любые побочные действия локализованы и прозрачно контролируются (транзакции, ретраи, логирование).


Коллекции + ленивость: когда важны память и скорость

При обработке больших наборов данных используйте генераторы (yield) или ленивые коллекции (в Laravel LazyCollection), чтобы не держать всё в памяти.

function numbers(): iterable { for ($i=0; $i < 10_000_000; $i++) { yield $i; } } $sum=0; foreach (numbers() as $n) { if ($n % 2===0) { $sum +=$n; } } 

В Laravel:

use Illuminate\Support\LazyCollection; $sum=LazyCollection::make(fn()=> numbers()) ->filter(fn($n)=> $n % 2===0) ->reduce(fn($acc, $n)=> $acc + $n, 0); 

Правило: по умолчанию — декларативные коллекции; для огромных потоков — ленивые итераторы/генераторы.


Типизация, readonly, enum и Value Objects

Современный PHP поощряет устойчивые модели данных.

enum OrderStatus: string { case NEW='new'; case PAID='paid'; case SHIPPED='shipped'; } final class OrderLine { public function __construct( public readonly string $sku, public readonly int $qty, public readonly int $priceCents, ) {} } final class Order { /** @param OrderLine[] $lines */ public function __construct( public readonly string $id, public OrderStatus $status, public readonly array $lines, ) {} public function total(): int { return array_reduce($this->lines, fn($s, $l)=> $s + $l->qty * $l->priceCents, 0); } } 
  • readonly защищает от случайных мутаций.

  • enum убирает «магические строки» в статусах.

  • Методы-агрегаты (total()) — чистые, легко тестируются.


Реалистичный конвейер доменной логики

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

Чистый конвейер:

/** @return iterable */ function paidOrdersForMonth(iterable $orders, DateTimeImmutable $month): iterable { foreach ($orders as $o) { if ($o->status===OrderStatus::PAID && $o->createdAt->format('Y-m')===$month->format('Y-m')) { yield $o; } } } function calculateBonusCents(Order $o): int { $total=$o->total(); return (int)round($total * 0.05); } function bonuses(iterable $orders, DateTimeImmutable $month): iterable { foreach (paidOrdersForMonth($orders, $month) as $o) { yield [$o->id, calculateBonusCents($o)]; } } 

Граница сайд-эффектов:

function persistBonuses(iterable $items, PDO $pdo): void { $stmt=$pdo->prepare('INSERT INTO bonuses(order_id, amount_cents) VALUES(?, ?)'); $pdo->beginTransaction(); try { foreach ($items as [$orderId, $amount]) { $stmt->execute([$orderId, $amount]); } $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } } function notify(iterable $items, callable $send): void { foreach ($items as [$orderId, $amount]) { $send("Order #$orderId bonus: $amount cents"); } } // Использование: $items=bonuses($ordersFromRepo, new DateTimeImmutable('now')); // можно разветвить поток: persistBonuses($items, $pdo); // если нужно уведомлять — пересоздайте генератор или предварительно материализуйте список: $items2=iterator_to_array(bonuses($ordersFromRepo, new DateTimeImmutable('now'))); notify($items2, $mailerSend); 

Замечание: генератор «однопроходный». Если нужно использовать результат несколько раз — материализуйте (iterator_to_array) или постройте конвейер так, чтобы источники данных были повторно создаваемы.


Ошибки и неочевидные моменты

  • Микрооптимизации против ясности. Цикл может быть быстрее конкретной цепочки коллекций, но выигрыш редко критичен. Пишите понятно — профилируйте только горячие участки.

  • Скрытая мутация ввода. Не меняйте входные массивы «по месту». Возвращайте новые структуры.

  • Сайд-эффекты в середине конвейера. Любая map() с DB::insert() — запах. Разделите: сначала подготовьте данные, затем выполните эффекты.

  • Неуправляемая ленивость. Генераторы «исчезают» после прохода. Будьте внимательны к повторному использованию результата.

  • Перемешивание валидации и эффектов. Сначала собираем все ошибки валидации (чисто), затем решаем, как реагировать (лог/исключения/ответ API).


Практические приёмы для команд

  1. Внутренний гайд «только коллекции»: договоритесь использовать map/filter/reduce/pipe и избегать ручных циклов в бизнес-логике.

  2. Чистые модули: отделяйте Domain (чисто) от Infrastructure (PDO, HTTP, очереди).

  3. Типизация везде: строгие сигнатуры, enum, readonly, DTO/Value Objects.

  4. Ленивые конвейеры для больших потоков: генераторы/LazyCollection.

  5. Тесты на трансформации: один тест — одна функция/шаг конвейера; фикстуры — мелкие и явные.

  6. Транзакции и ретраи — в оболочке: эффекты — централизованно, с контролем ошибок и метрик.

  7. Обёртки-адаптеры для эффектов: вместо глобальных фасадов — явная передача зависимостей (интерфейсы + DI).


Мини-шпаргалка (cheat sheet)

  • Преобразовать:

    • foreacharray_map / ->map()

    • фильтрация → array_filter / ->filter()

    • агрегаты → array_reduce / ->sum(), ->reduce()

  • Границы эффектов:

    • БД/HTTP/файлы/лог — только в сервисах оболочки (Application/Infrastructure).

  • Производительность:

    • Много данных → генераторы/LazyCollection

    • Горячие участки → профилировать, а не гадать.

  • Типизация:

    • Всегда указывать типы аргументов/возвратов; enum для дискретных состояний; readonly для DTO.


Заключение

PHP уже давно не «про шаблоны и echo». Декларативные коллекции, функциональная композиция и строгие границы сайд-эффектов делают код короче, надёжнее и проще для эволюции. Начните с малого: перепишите один «толстый» цикл в map/filter/reduce, вынесите работу с БД в отдельный слой и добавьте тесты на чистые функции. Вскоре вы заметите, что код начал служить бизнесу, а не наоборот.