Программирование на 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).
Практические приёмы для команд
-
Внутренний гайд «только коллекции»: договоритесь использовать
map/filter/reduce/pipe
и избегать ручных циклов в бизнес-логике. -
Чистые модули: отделяйте
Domain
(чисто) отInfrastructure
(PDO, HTTP, очереди). -
Типизация везде: строгие сигнатуры,
enum
,readonly
, DTO/Value Objects. -
Ленивые конвейеры для больших потоков: генераторы/
LazyCollection
. -
Тесты на трансформации: один тест — одна функция/шаг конвейера; фикстуры — мелкие и явные.
-
Транзакции и ретраи — в оболочке: эффекты — централизованно, с контролем ошибок и метрик.
-
Обёртки-адаптеры для эффектов: вместо глобальных фасадов — явная передача зависимостей (интерфейсы + DI).
Мини-шпаргалка (cheat sheet)
-
Преобразовать:
-
foreach
→array_map
/->map()
-
фильтрация →
array_filter
/->filter()
-
агрегаты →
array_reduce
/->sum()
,->reduce()
-
-
Границы эффектов:
-
БД/HTTP/файлы/лог — только в сервисах оболочки (Application/Infrastructure).
-
-
Производительность:
-
Много данных → генераторы/
LazyCollection
-
Горячие участки → профилировать, а не гадать.
-
-
Типизация:
-
Всегда указывать типы аргументов/возвратов;
enum
для дискретных состояний;readonly
для DTO.
-
Заключение
PHP уже давно не «про шаблоны и echo
». Декларативные коллекции, функциональная композиция и строгие границы сайд-эффектов делают код короче, надёжнее и проще для эволюции. Начните с малого: перепишите один «толстый» цикл в map/filter/reduce
, вынесите работу с БД в отдельный слой и добавьте тесты на чистые функции. Вскоре вы заметите, что код начал служить бизнесу, а не наоборот.