Imperative to declarative: loops into pipelines
Say what you want from a list, and let the pipeline do the walking.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–8Payoff
Steps 9–10Recap
Steps 11–12The code: before and after
Before
final readonly class Order
{
public function __construct(
public string $customer,
public string $status,
public int $total,
) {}
}
final class SalesReport
{
/** @param list<Order> $orders */
public function shippedRevenue(array $orders): int
{
$sum = 0;
foreach ($orders as $o) {
if ($o->status === 'SHIPPED') {
$sum += $o->total;
}
}
return $sum;
}
/**
* @param list<Order> $orders
* @return list<string>
*/
public function topCustomers(array $orders): array
{
$shipped = [];
foreach ($orders as $o) {
if ($o->status === 'SHIPPED') {
$shipped[] = $o;
}
}
usort($shipped, fn(Order $a, Order $b) => $b->total <=> $a->total);
$names = [];
foreach ($shipped as $o) {
if (count($names) === 3) {
break;
}
if (!in_array($o->customer, $names, true)) {
$names[] = $o->customer;
}
}
return $names;
}
}
final class DailyDigest
{
public static function run(SalesReport $report): void
{
$orders = [
new Order('ana@shop.com', 'SHIPPED', 120),
new Order('qa@test.shop', 'SHIPPED', 999),
new Order('bo@shop.com', 'PENDING', 80),
new Order('cy@shop.com', 'SHIPPED', 45),
new Order('ana@shop.com', 'SHIPPED', 60),
];
echo $report->shippedRevenue($orders), PHP_EOL;
echo implode(', ', $report->topCustomers($orders)), PHP_EOL;
}
}After
final readonly class Order
{
public function __construct(
public string $customer,
public string $status,
public int $total,
) {}
}
final class SalesReport
{
/** @param list<Order> $orders */
public function shippedRevenue(array $orders): int
{
$shipped = array_filter($orders, self::countsAsSale(...));
return array_sum(array_map(fn(Order $o) => $o->total, $shipped));
}
/**
* @param list<Order> $orders
* @return list<string>
*/
public function topCustomers(array $orders): array
{
$shipped = array_filter($orders, self::countsAsSale(...));
usort($shipped, fn(Order $a, Order $b) => $b->total <=> $a->total);
$names = array_map(fn(Order $o) => $o->customer, $shipped);
return array_slice(array_unique($names), 0, 3);
}
private static function countsAsSale(Order $o): bool
{
return $o->status === 'SHIPPED'
&& !str_ends_with($o->customer, '@test.shop');
}
}
final class DailyDigest
{
public static function run(SalesReport $report): void
{
$orders = [
new Order('ana@shop.com', 'SHIPPED', 120),
new Order('qa@test.shop', 'SHIPPED', 999),
new Order('bo@shop.com', 'PENDING', 80),
new Order('cy@shop.com', 'SHIPPED', 45),
new Order('ana@shop.com', 'SHIPPED', 60),
];
echo $report->shippedRevenue($orders), PHP_EOL;
echo implode(', ', $report->topCustomers($orders)), PHP_EOL;
}
}Transcript, step by step
Problem
Step 1: A sales report in loops Before
This sales report works on a list of orders, and each order has a customer email, a status and a total. Shipped revenue walks the list and adds the total of every shipped order to a running sum. Top customers copies the shipped orders into an array, sorts them by total, and walks them again to collect three different customers. The daily digest builds today's orders and prints both numbers. One of those orders comes from a test account at test dot shop, and it's the biggest order of the day.
Step 2: Leave out test orders Code smell
Finance says test orders, from customers at test dot shop, must not count in either number. So the check goes into the if in shipped revenue, and into the if in the first loop of top customers. This code is imperative, because it spells out how to walk the list step by step, so you have to replay the loop in your head to learn what it's for. You can see it in the running sum here, the temporary shipped array here, and the break and the in array check here.
Step 3: The rule, twice Code smell
The rule for what counts as a sale is now written twice, so the next rule has to be found and added twice too. And in top customers, the filter, the ranking, the repeat check and the stop at three are tangled across two loops. So you replay both loops line by line to see which one decides what counts as a sale, and where a new condition belongs.
Step 4: Back it out, then pipelines Plan
Let's back that change out. Declarative code says what you want, so we'll turn each loop into a collection pipeline, a series of array functions where each one has one job, and add the rule after that.
Refactor
Step 5: Shipped revenue as array functions Replace Loop with Pipeline
Shipped revenue now hands the orders to array filter, which keeps the shipped ones. Array map takes each total, and array sum adds them up, so you read that line from the inside out. The running sum is gone, because array sum keeps that total for you, and the digest still prints twelve twenty-four.
Step 6: The fill loop becomes a filter Replace Loop with Pipeline
In top customers, the loop that filled the shipped array becomes the same array filter. The sort stays as it was, because PHP sorts an array in place, so the filtered orders keep their name and get ranked by total. Nobody fills that array by hand anymore, and the second loop still reads from shipped, so the output stays the same.
Step 7: The second loop goes Replace Loop with Pipeline
Now the second loop goes too. Array map takes each order's customer, array unique drops the repeats, and array slice keeps the first three. The output doesn't change, because array unique keeps a name the first time it sees it, as the in array check did, and the slice of three returns what the break returned.
Step 8: Name the sale rule Extract Function
The status test now sits in two filters, so we extract it into one method called counts as sale. Both filters pass it by name as a first-class callable, first in shipped revenue and then in top customers. Now the diagram shows both methods asking one place what a sale is.
Payoff
Step 9: Test orders, one edit Payoff
Now Finance's rule goes in again, and this time it's one edit inside counts as sale. Neither method changed, because both of them ask counts as sale and don't care what it checks. So the digest now prints two twenty-five, with Ana and Cy as the top customers.
Step 10: Why it was easy Payoff
The running sum became array sum here, the hand-filled array became a filter and a sort here, and the break and the in array check became unique and slice here. Top customers now has one job per line, so you can see at a glance that a new sale rule belongs in the filter. Giving that rule one home for both methods came from Extract Function, and the one-job lines made the spot easy to find.
Recap
Step 11: Where we started, where we ended Before / after
On the left, shipped revenue keeps a running sum, and top customers fills a temporary array, checks in array, and breaks at three. On the right, top customers is a short run of array functions, and each line in it does one job. And counts as sale holds the rule for both of them, so Finance's next rule is one edit too.
Step 12: When to reach for it Takeaways
Reach for a pipeline when a loop filters, transforms or totals a collection, and you want each job readable on its own line. Skip it when each step depends on the one before, like a running balance that stops at the first overdraft, because the loop says that more plainly than array reduce. You'll spot it by a temporary array filled in a loop, a running total, or a break or flag that stops early. PHP's own array unique keeps the first copy of each value, so the repeats go and the order stays, which is what made the swap safe.