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