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.