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
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
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
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
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.
Print the receipt
Now combine the calculation functions with output. The script can print one line per item, then the final total.
<?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
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
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
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
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:
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:
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:
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:
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 integerunitPricepennies. - Implement
lineTotal(array $item): int. - Implement
subtotal(array $items): int. - Implement
formatMoney(int $pennies): stringwith explicit decimal and thousands separators. - Implement
formatReceiptLine(array $item): stringwithout 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
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
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
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.