PHP Language Basics

Milestone: Data Boundaries

This milestone combines arrays, strings, exceptions, and type declarations in one executable script. It prepares code for later file separation without using require before the next lesson teaches it.

The project is a product report. Raw array records are validated, converted into formatted lines, and summarised. Each helper returns a value; the top-level workflow decides what to print and how to handle rejected records.

Define The Input Shape

The report receives a list of product arrays:

PHP example
<?php

declare(strict_types=1);

$products = [
    ['sku' => 'BOOK-001', 'name' => ' PHP Workbook ', 'price_cents' => 2499],
    ['sku' => 'PEN-002', 'name' => 'Pen', 'price_cents' => 199],
    ['sku' => '', 'name' => 'Mug', 'price_cents' => 899],
    ['sku' => 'BAG-004', 'name' => '   ', 'price_cents' => 4200],
    ['sku' => 'PAD-005', 'name' => 'Notepad', 'price_cents' => 0],
];

An array type declaration cannot enforce these nested keys. The application must validate the record shape and business rules itself.

The policy is:

  • SKU and name must exist as strings and remain non-empty after trimming;
  • price must exist as an integer greater than zero;
  • valid text is trimmed;
  • money is formatted only for output;
  • one invalid product must not stop later products from being processed.

Normalise Required Text In One Helper

PHP example
<?php

function normaliseRequiredText(mixed $value, string $field): string
{
    if (!is_string($value)) {
        throw new InvalidArgumentException("$field must be text.");
    }

    $value = trim($value);

    if ($value === '') {
        throw new InvalidArgumentException("$field is required.");
    }

    return $value;
}

The boundary accepts mixed because an untrusted array key can contain any PHP value. The helper validates before promising a string result.

The $field argument adds useful developer context. Public output does not need to expose the exception message, but logs or tests can inspect it.

Validate The Complete Product Record

PHP example
<?php

function normaliseProduct(array $product): array
{
    $sku = normaliseRequiredText($product['sku'] ?? null, 'SKU');
    $name = normaliseRequiredText($product['name'] ?? null, 'Name');
    $priceCents = $product['price_cents'] ?? null;

    if (!is_int($priceCents) || $priceCents < 1) {
        throw new InvalidArgumentException('Price must be a positive integer.');
    }

    return [
        'sku' => $sku,
        'name' => $name,
        'price_cents' => $priceCents,
    ];
}

This helper returns a predictable array shape. It does not mutate the raw record, print output, or count errors.

The explicit is_int() check matters. Casting arbitrary values with (int) could turn null, false, or invalid text into 0 and hide the original shape problem.

Keep Formatting Separate From Validation

PHP example
<?php

function formatCents(int $cents): string
{
    return 'GBP ' . number_format($cents / 100, 2, '.', ',');
}

function formatProductLine(array $product): string
{
    return "{$product['sku']} | {$product['name']} | "
        . formatCents($product['price_cents']);
}

formatCents() owns the display convention. formatProductLine() owns the report layout. Neither function decides whether the line should be printed.

The formatter expects a normalised product. That assumption is established by call order in the workflow. Static-analysis array-shape annotations can document it more precisely later.

Process Every Record At The Top Level

PHP example
<?php

$validProducts = [];
$rejectedCount = 0;

foreach ($products as $index => $product) {
    try {
        $validProduct = normaliseProduct($product);
        $validProducts[] = $validProduct;

        echo formatProductLine($validProduct), PHP_EOL;
    } catch (InvalidArgumentException $exception) {
        $rejectedCount++;
        $recordNumber = $index + 1;

        echo "Rejected product record $recordNumber\n";
    }
}

echo 'Accepted: ' . count($validProducts) . PHP_EOL;
echo "Rejected: $rejectedCount\n";

Output:

BOOK-001 | PHP Workbook | GBP 24.99
PEN-002 | Pen | GBP 1.99
Rejected product record 3
Rejected product record 4
Rejected product record 5
Accepted: 2
Rejected: 3

The catch belongs inside the loop because rejection is per record. A catch around the entire loop would stop processing after the first invalid product.

The public message identifies the record without printing internal validation text. In a real command, the exception message could be logged separately.

Derive A Summary From Valid Data Only

The workflow stores normalised products separately, so later calculations do not need to repeat validation:

PHP example
<?php

$totalCents = 0;

foreach ($validProducts as $product) {
    $totalCents += $product['price_cents'];
}

echo 'Catalog total: ' . formatCents($totalCents) . PHP_EOL;

The result is GBP 26.98. Invalid records never enter $validProducts, so they cannot affect the total.

For this small milestone, a loop is clearer than a compact chain of array functions. The important invariant is that the calculation consumes the cleaned collection.

Identify Responsibility Boundaries

The completed script contains distinct responsibilities:

Responsibility Current owner
Raw example data Top-level script
Required-text validation normaliseRequiredText()
Product-shape validation normaliseProduct()
Currency display formatCents()
Report line layout formatProductLine()
Error handling and counters Top-level loop
Final output destination Top-level script

These boundaries matter before files are introduced. Moving poorly separated code into several files does not improve its design; it only spreads the confusion across paths.

The next lesson can move reusable functions to a helper file because they already receive inputs and return results. The main workflow can remain responsible for data, output, and process-level error handling.

Avoid Hidden Dependencies

A formatter should not read a global $currency variable. Either make the display convention part of the helper's explicit contract or pass it as an argument:

PHP example
<?php

function formatCentsForCurrency(int $cents, string $currency): string
{
    return $currency . ' ' . number_format($cents / 100, 2, '.', ',');
}

Explicit dependencies remain understandable when a function is moved, tested, or reused by another script.

Likewise, a validation helper should not increment a global rejected counter. It should return a clean value or throw. The caller owns the decision to count, skip, log, or terminate.

Test The Smallest Useful Units

Before file organisation, each helper can be checked independently:

PHP example
var_dump(normaliseRequiredText(' Ada ', 'Name') === 'Ada');
var_dump(formatCents(1299) === 'GBP 12.99');

Then test integration paths:

  • a fully valid record;
  • missing SKU;
  • whitespace-only name;
  • zero price;
  • wrong price PHP type;
  • several records where an invalid middle record does not stop the final valid one.

These cases define the contracts that must continue working after code moves between files.

Rehearse Extraction Without Adding Files Yet

Mark each top-level declaration as either reusable behavior or application flow.

Reusable behavior:

  • normaliseRequiredText();
  • normaliseProduct();
  • formatCents();
  • formatProductLine().

Application flow:

  • the $products input list;
  • accepted and rejected counters;
  • the processing loop;
  • public output;
  • the final catalog total.

If moving a reusable function would require copying an unrelated variable with it, that variable is probably a hidden dependency. Add a parameter or move the policy into the function's explicit contract before creating files.

Do not extract a helper merely because it is several lines long. Extract behavior that has a stable name, inputs, and result. Keeping the top-level workflow visible makes the report's execution order easy to trace.

Trace Data Across Each Boundary

For the first product, the path is:

  1. raw array enters normaliseProduct();
  2. SKU and name pass through required-text validation;
  3. integer price passes its range check;
  4. a cleaned product array is returned;
  5. the cleaned record is stored in $validProducts;
  6. formatProductLine() returns display text;
  7. the top-level script prints that text;
  8. the later total loop reads the cleaned integer price.

For the missing-SKU record, the path stops during required-text validation. The exception returns control to the per-record catch, which increments the rejected count. No formatter or total calculation sees that record.

This trace identifies the contract at every transition. It is also a test plan: each numbered boundary can be checked independently, while the complete path verifies integration.

Keep Units In Names

price_cents and $totalCents are intentionally explicit. A bare key such as price does not reveal whether the value is integer cents, a decimal string, or already formatted text.

File separation magnifies vague naming because the producer and consumer may no longer be visible together. Record units in keys, parameter names, and return descriptions before code is distributed across files.

Boundary Checklist

Before extracting helpers, verify:

  • every helper has explicit parameters and a return type;
  • raw array values are validated before typed use;
  • formatters return strings rather than printing;
  • exception handling occurs where a useful decision can be made;
  • counters belong to the workflow that owns them;
  • no helper reads hidden global state;
  • valid and invalid records have tested paths;
  • output formatting is separate from numeric calculation;
  • stored cleaned data is not HTML-escaped prematurely.

Common Milestone Mistakes

Symptom Likely cause
Invalid record changes the total Calculation used raw rather than cleaned data
Later products are never processed Catch was placed outside the loop
Helper is hard to reuse It prints or mutates global state
Missing key produces a warning Array value was read before validation
String price silently becomes zero Input was cast instead of checked
Currency rule is duplicated Formatting convention has no owning helper
Moving code would require many globals Responsibility boundaries are not explicit

What This Milestone Proves

You are ready for file organisation when you can validate array records, convert them into a predictable cleaned shape, format strings from typed values, catch record-level failures without stopping the collection, calculate only from accepted data, keep helpers free of hidden output and global state, and explain which responsibilities can move together later.

The exercises practise one formatter, one return-versus-output correction, and one responsibility plan. Actual require syntax begins in the next lesson.

Practice

Task: Format Valid Products

Task

Write formatProductLine(array $product): string in a strict PHP file.

The helper must trim name, require an integer price_cents greater than zero, throw InvalidArgumentException for an invalid record, and return <name>: <price> cents without printing.

Loop over these records and catch failures per record:

PHP example
$products = [
    ['name' => ' Notebook ', 'price_cents' => 1299],
    ['name' => '   ', 'price_cents' => 199],
    ['name' => 'Pen', 'price_cents' => 199],
];

Expected output:

Notebook: 1299 cents
Rejected product
Pen: 199 cents
Show solution

Solution

PHP example
<?php

declare(strict_types=1);

function formatProductLine(array $product): string
{
    $name = $product['name'] ?? null;
    $priceCents = $product['price_cents'] ?? null;

    if (!is_string($name) || trim($name) === '') {
        throw new InvalidArgumentException('Product name is required.');
    }

    if (!is_int($priceCents) || $priceCents < 1) {
        throw new InvalidArgumentException('Product price must be positive.');
    }

    return trim($name) . ": $priceCents cents";
}

$products = [
    ['name' => ' Notebook ', 'price_cents' => 1299],
    ['name' => '   ', 'price_cents' => 199],
    ['name' => 'Pen', 'price_cents' => 199],
];

foreach ($products as $product) {
    try {
        echo formatProductLine($product), PHP_EOL;
    } catch (InvalidArgumentException $exception) {
        echo "Rejected product\n";
    }
}

Explanation

The helper validates the array shape before returning one formatted string. Its caller owns output and per-record error handling.

Because the catch is inside the loop, the invalid middle record does not stop the valid Pen record from being processed.

Task: Fix Helper Return

Task

Refactor this helper so it returns a string:

PHP example
function productLine(array $product): void
{
    echo $product['name'] . ': ' . $product['price_cents'] . " cents\n";
}

Change its return type to string and remove output from inside it. Call it once for Pen at 199 cents.

Use the returned value twice:

CLI: Pen: 199 cents
Log entry: Pen: 199 cents

Hints

  • Assign the function call to $line.
  • The helper should decide the text, not its destination.
  • Both output statements belong to the caller.
Show solution

Solution

PHP example
<?php

declare(strict_types=1);

function productLine(array $product): string
{
    return $product['name'] . ': ' . $product['price_cents'] . ' cents';
}

$product = ['name' => 'Pen', 'price_cents' => 199];
$line = productLine($product);

echo "CLI: $line\n";
echo "Log entry: $line\n";

Explanation

The helper now owns only string construction. Its caller can reuse the returned line in multiple destinations without capturing or suppressing output from inside the function.

This contract is ready to move later because the function has explicit input and output and no hidden output side effect.

Task: Plan A Two-File Summary

Task

Given a one-file product report containing these elements:

  • formatCents(int $cents): string;
  • formatProductLine(array $product): string;
  • the $products input list;
  • a loop that prints each line;
  • accepted and rejected counters.

Write a two-column responsibility plan for future files named helpers.php and report.php.

Your plan must:

  • place reusable formatting functions in helpers.php;
  • keep input data, counters, control flow, and output in report.php;
  • identify every value a helper needs through parameters;
  • explain why no helper should echo or change a global counter.

Do not write require code yet; that syntax is introduced in the next lesson.

Show solution

Solution

Future file Responsibilities
helpers.php Define formatCents(int $cents): string and formatProductLine(array $product): string. Receive all required values through parameters and return strings.
report.php Define or obtain $products, own counters, loop over records, handle failures, call formatting helpers, and print the report.

Data would flow from report.php into a helper argument. The returned string would flow back to report.php for output.

Explanation

Formatting behavior is reusable and has clear input and output, so it is a suitable helper responsibility. The report's list, counters, and loop describe one application's execution and should remain together.

If a helper echoed, the report could not choose another destination easily. If it changed a global counter, the dependency would be hidden and tests would depend on shared state. The next lesson supplies the mechanics for loading the planned helper definitions.