PHP Language Basics

Project: CLI Receipt Calculator

A command-line receipt calculator is a good first project because it combines the PHP language features you have already learned without needing a browser, database, or framework. You work with arrays, strings, functions, type declarations, loops, arithmetic, and error handling in one small program.

The goal is not to build a full shop system. The goal is to practise turning simple input data into reliable output. That is the same shape as many junior PHP tasks: receive some data, validate it, calculate something, format it clearly, and make the failure cases predictable.

Start with the data

For this first version, keep the input data inside the script. That lets you concentrate on the PHP logic before adding command-line arguments or files later.

Each receipt line needs three pieces of information:

  • a product name
  • a quantity
  • a unit price

Use integer pennies or cents for prices. Avoid floats for money because decimal rounding can surprise you.

PHP example
<?php

declare(strict_types=1);

$items = [
    ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499],
    ['name' => 'Pen', 'quantity' => 3, 'unitPrice' => 129],
    ['name' => 'Desk pad', 'quantity' => 1, 'unitPrice' => 1299],
];

This is a list of associative arrays. The outer array is the receipt. Each inner array is one line item.

Calculate line totals

A line total is the quantity multiplied by the unit price. Put that calculation in a function so the rule has one clear name.

PHP example
<?php

declare(strict_types=1);

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

$item = ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499];

echo lineTotal($item);

// Prints:
// 998

The function returns an integer number of pennies. It does not format the value for display yet. Keeping calculation and display separate makes the code easier to test and reuse.

Format money at the edge

Humans do not want to read 998 when the value means 9.98. Add a small formatting function for output.

PHP example
<?php

declare(strict_types=1);

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

echo formatMoney(998);

// Prints:
// GBP 9.98

The calculation still uses integers. Formatting only happens when printing the receipt.

Build the subtotal

A subtotal is the sum of all line totals. Use a loop because a receipt can contain any number of items.

PHP example
<?php

declare(strict_types=1);

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

function subtotal(array $items): int
{
    $total = 0;

    foreach ($items as $item) {
        $total += lineTotal($item);
    }

    return $total;
}

$items = [
    ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499],
    ['name' => 'Pen', 'quantity' => 3, 'unitPrice' => 129],
];

echo subtotal($items);

// Prints:
// 1385

This loop is deliberately simple. A junior developer should be comfortable reading and writing this style before reaching for more compact array helpers.

Now combine the calculation functions with output. The script can print one line per item, then the final total.

PHP example
<?php

declare(strict_types=1);

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

function subtotal(array $items): int
{
    $total = 0;

    foreach ($items as $item) {
        $total += lineTotal($item);
    }

    return $total;
}

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

$items = [
    ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499],
    ['name' => 'Pen', 'quantity' => 3, 'unitPrice' => 129],
];

foreach ($items as $item) {
    echo $item['quantity'] . ' x ' . $item['name'];
    echo ' = ' . formatMoney(lineTotal($item)) . PHP_EOL;
}

echo 'Total: ' . formatMoney(subtotal($items)) . PHP_EOL;

// Prints:
// 2 x Notebook = GBP 9.98
// 3 x Pen = GBP 3.87
// Total: GBP 13.85

PHP_EOL prints the correct line ending for the current platform. In CLI scripts, it is clearer than embedding "\n" everywhere.

Add a discount rule

Business rules should be named. If orders of at least GBP 50 get GBP 5 off, put that rule in a function instead of hiding it inside a long output block.

PHP example
<?php

declare(strict_types=1);

function discountForSubtotal(int $subtotal): int
{
    if ($subtotal >= 5000) {
        return 500;
    }

    return 0;
}

$subtotal = 6200;
$discount = discountForSubtotal($subtotal);

echo $subtotal - $discount;

// Prints:
// 5700

The function accepts and returns pennies. That keeps the money rule consistent with the rest of the program.

Validate before calculating

Do not calculate with obviously bad data. A receipt item with an empty name, zero quantity, or negative price should be rejected near the boundary.

PHP example
<?php

declare(strict_types=1);

function validateItem(array $item): void
{
    if (trim($item['name'] ?? '') === '') {
        throw new InvalidArgumentException('Item name is required.');
    }

    if (($item['quantity'] ?? 0) < 1) {
        throw new InvalidArgumentException('Quantity must be at least 1.');
    }

    if (($item['unitPrice'] ?? -1) < 0) {
        throw new InvalidArgumentException('Unit price cannot be negative.');
    }
}

validateItem(['name' => 'Pen', 'quantity' => 3, 'unitPrice' => 129]);

echo 'Item accepted';

// Prints:
// Item accepted

Throwing an exception is useful here because bad input means the program should not continue as if the receipt is valid.

Handle failure for the CLI user

An exception message is useful to the developer, but a CLI program should still print a controlled message. Put the main work inside try and catch invalid input at the edge.

PHP example
<?php

declare(strict_types=1);

function validateItem(array $item): void
{
    if (trim($item['name'] ?? '') === '') {
        throw new InvalidArgumentException('Item name is required.');
    }
}

try {
    validateItem(['name' => '', 'quantity' => 1, 'unitPrice' => 100]);
    echo 'Receipt is valid' . PHP_EOL;
} catch (InvalidArgumentException $exception) {
    echo 'Cannot build receipt: ' . $exception->getMessage() . PHP_EOL;
}

// Prints:
// Cannot build receipt: Item name is required.

This is the same pattern you will use in larger applications: validate early, throw or return a clear failure, and keep the user-facing output controlled.

Validate the record shape, not only fallback values

A raw array may contain the key with the wrong PHP type. Validate before calling string or arithmetic operations:

PHP example
<?php

declare(strict_types=1);

function validateItem(array $item): void
{
    $name = $item['name'] ?? null;
    $quantity = $item['quantity'] ?? null;
    $unitPrice = $item['unitPrice'] ?? null;

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

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

    if (!is_int($unitPrice) || $unitPrice < 0) {
        throw new InvalidArgumentException('Unit price must be a non-negative integer.');
    }
}

This rejects '2' as a quantity rather than relying on implicit conversion. If input later comes from $argv, add a parser that validates and converts the raw string before constructing an item array.

Zero unit price is allowed by this policy because a free item can be valid. Quantity must still be at least one.

Validate the whole receipt before printing

Printing each line while validating can leave a partial receipt when a later item fails. For an all-or-nothing receipt, validate every record first:

PHP example
foreach ($items as $item) {
    validateItem($item);
}

foreach ($items as $item) {
    echo formatReceiptLine($item), PHP_EOL;
}

Alternatively, build all lines in an array and print them only after successful validation. The output policy should be explicit: either partial progress is acceptable, or the receipt is atomic.

A formatter keeps line layout out of the entry-point loop:

PHP example
function formatReceiptLine(array $item): string
{
    return $item['quantity'] . ' x ' . trim($item['name'])
        . ' = ' . formatMoney(lineTotal($item));
}

Use standard error and a failure exit code

A CLI command should make failure machine-detectable:

PHP example
try {
    foreach ($items as $item) {
        validateItem($item);
    }

    echo "Receipt ready\n";
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, 'Cannot build receipt: ' . $exception->getMessage() . PHP_EOL);
    exit(1);
}

Normal receipt output goes to standard output. Diagnostics go to STDERR. Exit code 0 indicates success; non-zero indicates failure to shell scripts, CI, and other callers.

Keep exception messages deliberately safe if they are shown directly. Do not include secrets or raw payloads in them.

Check discount boundaries

A threshold rule needs values immediately around its boundary:

PHP example
var_dump(discountForSubtotal(4999));
var_dump(discountForSubtotal(5000));
var_dump(discountForSubtotal(5001));

Expected results are 0, 500, and 500. These checks prove the rule uses >= rather than >.

Also ensure a discount never makes the final total negative. A fixed GBP 5 discount is safe when it applies only to subtotals of at least GBP 50, but future discount rules should clamp or validate their result deliberately.

Verify the receipt with a case matrix

Run at least these cases:

Case Expected behavior
One normal item One line and matching total
Several items Subtotal equals sum of line totals
Zero-price item Accepted under the stated policy
Empty name Rejected before any receipt output
String quantity Rejected as the wrong array value type
Quantity zero Rejected by the range rule
Negative price Rejected by the price rule
Subtotal 4999 No discount
Subtotal 5000 GBP 5 discount

Reconcile the final total with subtotal - discount. Predict exact output and exit status before running each failure case.

What a good solution includes

A good version of this project should have small named functions for the important rules. It should calculate money using integer pennies, format money only when printing, loop over all receipt lines, and reject invalid item data before totals are calculated.

It should also be easy to run from the terminal:

php receipt.php

Before moving on, make sure you can explain how the receipt data flows through validation, calculation, discounting, and output.

Practice

Task: Build A Receipt Calculator

Create receipt.php using trusted hard-coded data.

Requirements

  • Enable strict types.
  • Store receipt items as arrays with name, quantity, and integer unitPrice pennies.
  • Implement lineTotal(array $item): int.
  • Implement subtotal(array $items): int.
  • Implement formatMoney(int $pennies): string with explicit decimal and thousands separators.
  • Implement formatReceiptLine(array $item): string without printing inside it.
  • Print every line and the final total.

Use Notebook 2 x 499, Pen 3 x 129, and Desk pad 1 x 1299.

Expected output:

2 x Notebook = GBP 9.98
3 x Pen = GBP 3.87
1 x Desk pad = GBP 12.99
Total: GBP 26.84

Explain why line totals and the subtotal stay integers until formatting.

Show solution
PHP example
<?php

declare(strict_types=1);

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

function subtotal(array $items): int
{
    $total = 0;

    foreach ($items as $item) {
        $total += lineTotal($item);
    }

    return $total;
}

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

function formatReceiptLine(array $item): string
{
    return $item['quantity'] . ' x ' . $item['name']
        . ' = ' . formatMoney(lineTotal($item));
}

$items = [
    ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499],
    ['name' => 'Pen', 'quantity' => 3, 'unitPrice' => 129],
    ['name' => 'Desk pad', 'quantity' => 1, 'unitPrice' => 1299],
];

foreach ($items as $item) {
    echo formatReceiptLine($item), PHP_EOL;
}

echo 'Total: ' . formatMoney(subtotal($items)) . PHP_EOL;

Explanation

Calculation helpers return integer pennies, so totals do not accumulate binary floating-point rounding errors. formatMoney() performs the decimal conversion only at presentation time.

The line formatter returns a string, leaving the entry-point loop responsible for output. The subtotal is 998 + 387 + 1299 = 2684 pennies.

Task: Add Discount Rule
function discountForSubtotal(int $subtotal): int

Return 500 pennies when subtotal is at least 5000; otherwise return 0.

Before printing the receipt, verify the function returns 0, 500, and 500 for subtotals 4999, 5000, and 5001. Then use Keyboard at 3499 and Mouse at 1899.

Print:

1 x Keyboard = GBP 34.99
1 x Mouse = GBP 18.99
Subtotal: GBP 53.98
Discount: GBP 5.00
Total: GBP 48.98

Keep the discount calculation separate from formatting and output.

Show solution
PHP example
<?php

declare(strict_types=1);

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

function subtotal(array $items): int
{
    $total = 0;

    foreach ($items as $item) {
        $total += lineTotal($item);
    }

    return $total;
}

function discountForSubtotal(int $subtotal): int
{
    return $subtotal >= 5000 ? 500 : 0;
}

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

function formatReceiptLine(array $item): string
{
    return $item['quantity'] . ' x ' . $item['name']
        . ' = ' . formatMoney(lineTotal($item));
}

if (
    discountForSubtotal(4999) !== 0
    || discountForSubtotal(5000) !== 500
    || discountForSubtotal(5001) !== 500
) {
    throw new RuntimeException('Discount boundary check failed.');
}

$items = [
    ['name' => 'Keyboard', 'quantity' => 1, 'unitPrice' => 3499],
    ['name' => 'Mouse', 'quantity' => 1, 'unitPrice' => 1899],
];

foreach ($items as $item) {
    echo formatReceiptLine($item), PHP_EOL;
}

$subtotal = subtotal($items);
$discount = discountForSubtotal($subtotal);
$total = $subtotal - $discount;

echo 'Subtotal: ' . formatMoney($subtotal) . PHP_EOL;
echo 'Discount: ' . formatMoney($discount) . PHP_EOL;
echo 'Total: ' . formatMoney($total) . PHP_EOL;

Explanation

The three checks protect the exact >= 5000 boundary without adding output on success. The receipt subtotal is 5398, so the 500-penny discount applies and leaves 4898 pennies.

All business-rule values remain integers. Formatting happens only for the final report strings.

Task: Reject Invalid Receipt Items

It must reject:

  • a missing, non-string, or whitespace-only name;
  • a non-integer quantity or quantity below 1;
  • a non-integer unit price or price below 0.

Validate every item before printing any receipt line. Catch InvalidArgumentException at the CLI boundary, write Cannot build receipt: <message> to STDERR, and exit with code 1.

Test with a valid Notebook followed by a whitespace-only name. No partial receipt line should appear. The diagnostic should be:

Cannot build receipt: Item name is required.
Show solution
PHP example
<?php

declare(strict_types=1);

function validateItem(array $item): void
{
    $name = $item['name'] ?? null;
    $quantity = $item['quantity'] ?? null;
    $unitPrice = $item['unitPrice'] ?? null;

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

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

    if (!is_int($unitPrice) || $unitPrice < 0) {
        throw new InvalidArgumentException('Unit price must be a non-negative integer.');
    }
}

function lineTotal(array $item): int
{
    return $item['quantity'] * $item['unitPrice'];
}

function subtotal(array $items): int
{
    $total = 0;

    foreach ($items as $item) {
        $total += lineTotal($item);
    }

    return $total;
}

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

$items = [
    ['name' => 'Notebook', 'quantity' => 2, 'unitPrice' => 499],
    ['name' => '   ', 'quantity' => 1, 'unitPrice' => 129],
];

try {
    foreach ($items as $item) {
        validateItem($item);
    }

    foreach ($items as $item) {
        echo $item['quantity'] . ' x ' . trim($item['name'])
            . ' = ' . formatMoney(lineTotal($item)) . PHP_EOL;
    }

    echo 'Total: ' . formatMoney(subtotal($items)) . PHP_EOL;
} catch (InvalidArgumentException $exception) {
    fwrite(STDERR, 'Cannot build receipt: ' . $exception->getMessage() . PHP_EOL);
    exit(1);
}

Explanation

Shape and range checks happen in one validation pass before output, so the failure is atomic: no Notebook line is printed before the second item is rejected.

The entry point uses standard error and exit code 1, allowing a shell or CI job to distinguish failure from a successful receipt. The shown exception messages are intentionally safe CLI validation messages.