Программирование на 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, вынесите работу с БД в отдельный слой и добавьте тесты на чистые функции. Вскоре вы заметите, что код начал служить бизнесу, а не наоборот.