PHP Programming in the 21st Century: Collections instead of loops, functional thinking, and separating data preparation from side effects

readonlyenummatchstrlen(...)


Why collections are better than "manual" loops

forforeach

  • Declarative:

  • The composition:

  • Fewer states:

  • Testability:

Example: filter, projection and aggregate

Before (imperative):

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

After (pure PHP, no frameworks):

$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);

With a collection (for example, 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()


Functional programming in PHP: basic techniques

Pure functions and immutable data

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);
}

Higher-order functions and "first-class" callbacks

In PHP 8.1+, you can get a callable "as a value":

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

It's convenient for composition.:

$trim = fn (string $s): string => trim ($s);
$upper = fn (string $s): string = > mb_strtoupper($s);
$format = fn (string $s): string => $top($trim($s));

echo $format ("hi"); / / "HI"

Currying and partial application

$multiply = fn(int $a) => fn(int $b) => $a * $b;
$double   = $multiply(2);

echo $double(21); // 42

Composition via pipes

pipe()

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

Separation of data preparation from side effects ("Functional Core, Imperative Shell")

Idea:

Example: importing CSV clients

Step 1. Clean data preparation

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

Step 2. Imperative shell — a single point of side effects

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);

Advantages: pure functions are easily covered by tests; any side effects are localized and transparently monitored (transactions, backups, logging).


Collections + Laziness: when memory and speed are important

generatorsyieldlazy collectionsLazyCollection

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;
    }
}

In Laravel:

use Illuminate\Support\LazyCollection;

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

The rule:


readonlyenum

Modern PHP encourages resilient data models.

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()


Realistic pipeline of domain logic

Suppose you need to calculate bonuses for paid orders for the current month and send notifications.

A clean conveyor:

/** @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)];
    }
}

The border of side effects:

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


Mistakes and non-obvious points

  • Micro-optimizations versus clarity.

  • Hidden mutation of the input.

  • Side effects in the middle of the pipeline.map()DB::insert()

  • Uncontrollable laziness.

  • Mixing validation and effects.all


Practical techniques for teams

  1. The "collections only" internal guide:map/filter/reduce/pipe

  2. Clean modules:DomainInfrastructure

  3. Typing is everywhere:enumreadonly

  4. Lazy pipelines for large streams:LazyCollection

  5. Transformation tests:

  6. Transactions and retrays — in the shell:

  7. Wrappers-adapters for effects:


Mini cheat sheet

  • Convert:

    • foreacharray_map->map()

    • array_filter->filter()

    • array_reduce->sum()->reduce()

  • Effect boundaries:

    • Database/HTTP/files/log — only in shell services (Application/Infrastructure).

  • Efficiency:

    • LazyCollection

    • Hot spots → profile, not guess.

  • Typing:

    • enumreadonly


Conclusion

echomap/filter/reduce