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
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
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
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
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
$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
$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
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:
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
$productsinput 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:
- raw array enters
normaliseProduct(); - SKU and name pass through required-text validation;
- integer price passes its range check;
- a cleaned product array is returned;
- the cleaned record is stored in
$validProducts; formatProductLine()returns display text;- the top-level script prints that text;
- 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:
$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
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:
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
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
$productsinput 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.