Project: Multi-File Price Report
This capstone takes a working one-file report and separates it without changing its behavior. The project remains deliberately small, but every file has an explicit contract and the entry point validates values crossing file boundaries.
The final structure is:
price-report/
settings.php
data.php
functions.php
report.php
settings.phpreturns display configuration;data.phpreturns raw product records;functions.phpdefines validation, calculation, and formatting behavior;report.phploads dependencies and coordinates the command.
Return Settings Without Side Effects
settings.php:
<?php
declare(strict_types=1);
return [
'currency' => 'GBP',
'decimal_separator' => '.',
'thousands_separator' => ',',
];
The file prints nothing and changes no global state. Its contract is one returned array.
A returned config file is appropriate for this small project. Larger applications commonly load environment-specific configuration through a framework or dedicated configuration system.
Return Product Data Separately
data.php:
<?php
declare(strict_types=1);
return [
['sku' => 'BOOK-001', 'name' => 'Notebook', 'price_pence' => 499],
['sku' => 'PEN-002', 'name' => 'Pen', 'price_pence' => 129],
['sku' => 'PAD-003', 'name' => 'Desk pad', 'price_pence' => 1299],
];
The unit is recorded in price_pence. A generic price key would not reveal whether the value was integer pennies, a decimal number, or formatted text.
This file is trusted project data for the exercise, but report.php still validates the broad returned type and the helper validates each record. File separation does not make data automatically correct.
Put Reusable Rules In The Definition File
functions.php begins with strict types and contains no top-level output:
<?php
declare(strict_types=1);
function normaliseProduct(array $product): array
{
$sku = $product['sku'] ?? null;
$name = $product['name'] ?? null;
$pricePence = $product['price_pence'] ?? null;
if (!is_string($sku) || trim($sku) === '') {
throw new InvalidArgumentException('Product SKU is required.');
}
if (!is_string($name) || trim($name) === '') {
throw new InvalidArgumentException('Product name is required.');
}
if (!is_int($pricePence) || $pricePence < 0) {
throw new InvalidArgumentException('Product price must be non-negative integer pence.');
}
return [
'sku' => trim($sku),
'name' => trim($name),
'price_pence' => $pricePence,
];
}
Returning a cleaned shape keeps later functions simple. Wrong types are rejected rather than cast into misleading values.
Pass Configuration Into Formatters
Do not make formatMoney() reach into a global settings array:
<?php
function formatMoney(int $pence, array $settings): string
{
return $settings['currency'] . ' ' . number_format(
$pence / 100,
2,
$settings['decimal_separator'],
$settings['thousands_separator'],
);
}
function reportLine(array $product, array $settings): string
{
return $product['sku'] . ' | ' . $product['name'] . ' | '
. formatMoney($product['price_pence'], $settings);
}
The configuration dependency is visible in both signatures and calls. The helpers can be tested with another currency label without mutating process-wide state.
For a larger project, a dedicated value object or service could express this dependency more strongly. This track remains with functions and arrays.
Load And Validate File Results
report.php is the executable entry point:
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
$settings = require __DIR__ . '/settings.php';
$rawProducts = require __DIR__ . '/data.php';
if (!is_array($settings)) {
throw new RuntimeException('Settings file must return an array.');
}
if (!is_array($rawProducts) || !array_is_list($rawProducts)) {
throw new RuntimeException('Data file must return a product list.');
}
Definitions are loaded with require_once to prevent accidental redeclaration. Returned data files use require because each result is assigned deliberately.
__DIR__ anchors every path to the project, not the shell's working directory.
Validate Settings Before Formatting
The broad array check is not enough:
<?php
$requiredSettingKeys = [
'currency',
'decimal_separator',
'thousands_separator',
];
foreach ($requiredSettingKeys as $key) {
if (!isset($settings[$key]) || !is_string($settings[$key])) {
throw new RuntimeException("Invalid setting: $key");
}
}
A real formatter may impose additional rules, such as one-character separators and an approved currency code. The important habit is to validate each file's contract before passing its data deeper.
Build Output Before Printing
For an atomic report, normalise every product and build every line before writing output:
<?php
$products = [];
$lines = [];
$totalPence = 0;
foreach ($rawProducts as $rawProduct) {
if (!is_array($rawProduct)) {
throw new InvalidArgumentException('Every product must be an array.');
}
$product = normaliseProduct($rawProduct);
$products[] = $product;
$lines[] = reportLine($product, $settings);
$totalPence += $product['price_pence'];
}
$lines[] = 'Total: ' . formatMoney($totalPence, $settings);
echo implode(PHP_EOL, $lines), PHP_EOL;
Expected output:
BOOK-001 | Notebook | GBP 4.99
PEN-002 | Pen | GBP 1.29
PAD-003 | Desk pad | GBP 12.99
Total: GBP 19.27
If a later product is invalid, no partial report has been printed. The command can fail as one operation.
The $products collection is optional for this output, but retaining cleaned records can support later summaries. Do not keep data without a reason in a memory-sensitive import.
Handle Expected Failure At The Entry Point
Wrap the executable workflow rather than adding catches inside every helper:
<?php
try {
// Load, validate, normalise, calculate, and build output.
} catch (InvalidArgumentException | RuntimeException $exception) {
fwrite(STDERR, 'Cannot build report: ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
The helper functions communicate failure; the command decides how to report and terminate. In production, public messages and private logs may need separate detail.
Do not catch Throwable merely to force every programming defect into the same success-looking path. A top-level catch can log unexpected failures, but it should still exit unsuccessfully.
Understand The Dependency Direction
The entry point depends on definitions and returned data:
report.php
-> functions.php
-> settings.php
-> data.php
functions.php does not require report.php. Data and settings files do not load the entry point. This one-way direction prevents circular loading and keeps execution ownership obvious.
If helper files begin requiring each other unpredictably, the project is outgrowing manual function includes. Composer autoloading and namespaces are introduced in later tracks for classes and larger codebases.
Run From More Than One Working Directory
From the project directory:
php report.php
From elsewhere:
php /absolute/path/to/price-report/report.php
Both commands should produce identical output because every project dependency is based on __DIR__.
Also run syntax checks:
php -l report.php
php -l functions.php
php -l settings.php
php -l data.php
A parse error in any required file prevents the command from completing.
Test File Contracts Independently
Useful checks include:
- settings and data files return arrays;
- data is a zero-based list;
- every record is an array;
- missing SKU or name is rejected;
- wrong price type is rejected;
- zero price is accepted under the stated rule;
- formatting uses supplied settings;
- total equals the sum of normalised prices;
- a bad final record produces no partial report;
- running from another directory does not break paths;
- requiring the function file twice with
require_oncedoes not redeclare functions.
These tests protect behavior while files move or new entry points are added.
Keep Changes Local To The Owning File
A currency display change belongs in settings or formatting behavior, not in each product record. A new product belongs in data. A new validation rule belongs in normaliseProduct(). Output ordering belongs in the report workflow.
Clear ownership is the practical benefit of the split. The number of files itself is not a quality metric.
Preserve Behavior During Extraction
If starting from a one-file report, record its exact success output and failure behavior before moving code. Extract one responsibility at a time: functions first, then settings, then data. Run the command after each move.
File extraction should not silently change validation order, total units, exception messages, output destinations, or exit codes. A refactor is successful when ownership becomes clearer while observable behavior and invariants remain protected.
Use git diff to confirm that a move did not also introduce unrelated rule changes. When a rule must change, make that a separate deliberate step with its own boundary cases.
Common Multi-File Mistakes
| Symptom | Likely cause |
|---|---|
| Script works only from project directory | Require path is not anchored with __DIR__ |
| Helper function is declared twice | Definition file loaded repeatedly with require |
Assigned config equals 1 |
Loaded file did not return a value |
| Formatter depends on unexplained currency | Settings are hidden global state |
| Invalid late record leaves partial output | Lines were printed before full validation |
| Strict behavior differs by entry point | One calling file lacks strict_types |
| File load produces unexpected text | Data or definition file has a side effect |
| Circular includes appear | Dependency direction is not controlled |
| Total includes rejected data | Calculation used raw rather than normalised records |
Completion Checklist
The capstone is complete when:
- every PHP file declares strict types;
- every require path is source-relative;
- returned file values are assigned and validated;
- helper definitions produce no load-time output;
- dependencies are passed explicitly;
- raw product shapes are validated;
- all output is built from normalised data;
- total arithmetic remains integer pence;
- failure writes to standard error and exits non-zero;
- success output matches exactly from different working directories;
- all files pass
php -l.
If you can explain each file's contract, trace one product through loading, validation, formatting, and total calculation, and identify where a new rule belongs, you have completed the PHP language basics track with a working multi-file program.
Practice
Task: Build A Price Report
Create data.php, functions.php, and report.php.
- Every file declares strict types.
data.phpreturns products withsku,name, and integerprice_pence.functions.phpdefinesformatMoney(int $pence): stringandreportLine(array $product): stringand prints nothing.report.phpusesrequire_once __DIR__ . '/functions.php', assigns the returned data file, verifies it is a list, and prints each line.
Use Notebook 499, Pen 129, and Desk pad 1299. Run the report from its directory and by absolute path elsewhere.
Expected lines begin with the SKU, for example:
BOOK-001 | Notebook | GBP 4.99
Show solution
<?php
declare(strict_types=1);
return [
['sku' => 'BOOK-001', 'name' => 'Notebook', 'price_pence' => 499],
['sku' => 'PEN-002', 'name' => 'Pen', 'price_pence' => 129],
['sku' => 'PAD-003', 'name' => 'Desk pad', 'price_pence' => 1299],
];
functions.php:
<?php
declare(strict_types=1);
function formatMoney(int $pence): string
{
return 'GBP ' . number_format($pence / 100, 2, '.', ',');
}
function reportLine(array $product): string
{
return $product['sku'] . ' | ' . $product['name'] . ' | '
. formatMoney($product['price_pence']);
}
report.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
$products = require __DIR__ . '/data.php';
if (!is_array($products) || !array_is_list($products)) {
throw new RuntimeException('Data file must return a product list.');
}
foreach ($products as $product) {
echo reportLine($product), PHP_EOL;
}
Explanation
The entry point owns loading and output, while the other files return data or define reusable behavior. Unit-explicit price_pence prevents ambiguity.
Both require paths are anchored to report.php, so changing the shell's working directory does not change dependency resolution.
Task: Fix Settings Require
[
'currency' => 'GBP',
'decimal_separator' => '.',
'thousands_separator' => ',',
]
The broken entry point requires the file without assigning its returned value, then reads undefined $settings.
Fix report.php so it:
- assigns the
requireexpression; - verifies the result is an array;
- verifies
currencyexists and is a string; - prints
GBP.
Explain why a successful required file with no explicit return would otherwise produce integer 1 when assigned.
Show solution
<?php
declare(strict_types=1);
return [
'currency' => 'GBP',
'decimal_separator' => '.',
'thousands_separator' => ',',
];
report.php:
<?php
declare(strict_types=1);
$settings = require __DIR__ . '/settings.php';
if (!is_array($settings)) {
throw new RuntimeException('Settings file must return an array.');
}
if (!isset($settings['currency']) || !is_string($settings['currency'])) {
throw new RuntimeException('Currency setting must be a string.');
}
echo $settings['currency'], PHP_EOL;
Explanation
require is an expression, so the caller must assign the value returned by settings.php. Requiring without assignment evaluates the file but does not create a $settings variable automatically.
A successfully loaded file with no explicit return yields integer 1. The array check catches that broken file contract before a key is read.
Task: Complete The Multi-File Report
Build settings.php, data.php, functions.php, and report.php.
Requirements:
- returned file values are assigned and validated;
- product records require non-empty string SKU/name and non-negative integer
price_pence; - currency settings are passed into formatters, not read globally;
- all product lines are built before any output;
- the report prints a final integer-pence total;
- expected validation failures write to
STDERRand exit1; - success works from another working directory.
Use the three products from exercise 1. Expected final line:
Total: GBP 19.27
Show solution
<?php
declare(strict_types=1);
return ['currency' => 'GBP', 'decimal_separator' => '.', 'thousands_separator' => ','];
data.php:
<?php
declare(strict_types=1);
return [
['sku' => 'BOOK-001', 'name' => 'Notebook', 'price_pence' => 499],
['sku' => 'PEN-002', 'name' => 'Pen', 'price_pence' => 129],
['sku' => 'PAD-003', 'name' => 'Desk pad', 'price_pence' => 1299],
];
functions.php:
<?php
declare(strict_types=1);
function normaliseProduct(array $product): array
{
$sku = $product['sku'] ?? null;
$name = $product['name'] ?? null;
$pricePence = $product['price_pence'] ?? null;
if (!is_string($sku) || trim($sku) === '') {
throw new InvalidArgumentException('Product SKU is required.');
}
if (!is_string($name) || trim($name) === '') {
throw new InvalidArgumentException('Product name is required.');
}
if (!is_int($pricePence) || $pricePence < 0) {
throw new InvalidArgumentException('Product price must be non-negative integer pence.');
}
return ['sku' => trim($sku), 'name' => trim($name), 'price_pence' => $pricePence];
}
function formatMoney(int $pence, array $settings): string
{
return $settings['currency'] . ' ' . number_format(
$pence / 100,
2,
$settings['decimal_separator'],
$settings['thousands_separator'],
);
}
function reportLine(array $product, array $settings): string
{
return $product['sku'] . ' | ' . $product['name'] . ' | '
. formatMoney($product['price_pence'], $settings);
}
report.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
try {
$settings = require __DIR__ . '/settings.php';
$rawProducts = require __DIR__ . '/data.php';
if (!is_array($settings)) {
throw new RuntimeException('Settings must return an array.');
}
if (!is_array($rawProducts) || !array_is_list($rawProducts)) {
throw new RuntimeException('Data must return a product list.');
}
foreach (['currency', 'decimal_separator', 'thousands_separator'] as $key) {
if (!isset($settings[$key]) || !is_string($settings[$key])) {
throw new RuntimeException("Invalid setting: $key");
}
}
$lines = [];
$totalPence = 0;
foreach ($rawProducts as $rawProduct) {
if (!is_array($rawProduct)) {
throw new InvalidArgumentException('Every product must be an array.');
}
$product = normaliseProduct($rawProduct);
$lines[] = reportLine($product, $settings);
$totalPence += $product['price_pence'];
}
$lines[] = 'Total: ' . formatMoney($totalPence, $settings);
echo implode(PHP_EOL, $lines), PHP_EOL;
} catch (InvalidArgumentException | RuntimeException $exception) {
fwrite(STDERR, 'Cannot build report: ' . $exception->getMessage() . PHP_EOL);
exit(1);
}
Explanation
The dependency direction is one-way from the entry point. Returned values and nested records are checked before use, while formatters receive settings explicitly.
The report collects every line before the first echo, so a bad late record cannot leave partial success output. The three prices total 1927 pence.