Writing clear code
Three rules keep a function readable and debuggable: assign each value once, never nest one call's result inside another, and return a data object rather than writing back through a reference argument. Together they make code that reads top to bottom, one named step at a time.
Assign each value once
Give a variable its value where you declare it, using a ternary or the result of a
call, and do not reassign it afterwards. A value set once is easy to follow: you read the
declaration and know what it holds for the rest of its scope. Build lists with
array_map and array_filter rather than declaring an empty array
and appending to it in a loop.
$label = $count === 0 ? 'empty' : formatCount($count);
$active = array_filter($members, function(MemberData $member): bool {
return $member->isActive;
});
$names = array_map(function(MemberData $member): string {
return $member->name;
}, $active);
One step per line
Break a calculation into multiple lines, one named variable per intermediate step, rather than packing it into a single dense nested expression. Each name is assigned once and explains what that step produced, so the sequence reads top to bottom and a debugger can stop on any value.
Dense and hard to follow:
$total = round(applyTax(sumLines($order->lines), $order->taxRate) * $order->quantity, 2);
The same work, one single-assignment step at a time:
$subtotal = sumLines($order->lines);
$taxed = applyTax($subtotal, $order->taxRate);
$lineTotal = $taxed * $order->quantity;
$total = round($lineTotal, 2);
No nested call arguments
Never pass the result of one function call straight in as an argument to another call. Assign it to a well-named variable first, then pass that variable. Stated positively, a function argument is only ever one of three things: a constant, a variable, or a closure. If you find a call nested inside another call's arguments, lift it out and give it a name. Each step then has a name, the code reads in order, and a debugger can stop on any intermediate value.
// Avoid: a call nested inside another call's arguments.
saveOrder(validateOrder(parsePayload($raw)));
// Prefer: each result is a named variable.
$parsed = parsePayload($raw);
$validated = validateOrder($parsed);
$order = saveOrder($validated);
Closure parameters are typed callable, so an argument can be either an
inline closure or a string naming a function. That keeps the rule intact: a string like
'ShopProduct::Render' is a constant that happens to name a function, so you
still never nest a call. See Declarative code for
how this pairs with the Collection and Logic helpers.
// An inline closure.
CollectionArray::Each($products, function(ShopProductData $product): void {
ShopProduct::Render($product);
});
// The same, passing a string that names the function.
CollectionArray::Each($products, 'ShopProduct::Render');
No reference arguments
In application code a function takes its inputs as arguments and returns its result.
There are no &$out reference parameters written to as a side effect. To
produce several values, define a Data object that holds those fields and
return it. The inputs and outputs of a function are then visible in its signature: you
read the parameters and the return type and know what goes in and what comes out.
final class PriceData {
public function __construct(
public readonly int $net,
public readonly int $tax,
public readonly int $gross,
) {}
}
function priceLine(int $unit, int $quantity, int $taxRate): PriceData {
$net = $unit * $quantity;
$tax = intdiv($net * $taxRate, 100);
$gross = $net + $tax;
return new PriceData($net, $tax, $gross);
}
Returning a named object rather than mutating a caller's variable keeps data flow one
way, which is what lets the single-assignment style above hold everywhere. See
Objects and types for the shape of the
Data object a function returns.
Next: Errors and the happy path.