PHP Language Basics

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.php returns display configuration;
  • data.php returns raw product records;
  • functions.php defines validation, calculation, and formatting behavior;
  • report.php loads dependencies and coordinates the command.

Return Settings Without Side Effects

settings.php:

PHP example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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_once does 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.php returns products with sku, name, and integer price_pence.
  • functions.php defines formatMoney(int $pence): string and reportLine(array $product): string and prints nothing.
  • report.php uses require_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 example
<?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 example
<?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 example
<?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 require expression;
  • verifies the result is an array;
  • verifies currency exists 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 example
<?php

declare(strict_types=1);

return [
    'currency' => 'GBP',
    'decimal_separator' => '.',
    'thousands_separator' => ',',
];

report.php:

PHP example
<?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 STDERR and exit 1;
  • success works from another working directory.

Use the three products from exercise 1. Expected final line:

Total: GBP 19.27
Show solution
PHP example
<?php

declare(strict_types=1);

return ['currency' => 'GBP', 'decimal_separator' => '.', 'thousands_separator' => ','];

data.php:

PHP example
<?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 example
<?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 example
<?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.