Level: beginner. You will build a calculator page: two numbers, an operation, a result. It practises forms, functions, classes, match and error handling.
What it does
- Takes two numbers and an operation: add, subtract, multiply, divide, power, remainder.
- Validates the input (real numbers only).
- Shows a friendly message for division by zero.
- Keeps the values in the form after you press Calculate.
Step 1: the calculator class
Put the logic in a class that knows nothing about HTML. That makes it easy to test.
<?php
declare(strict_types=1);
final class Calculator
{
public const OPERATIONS = [
"add" => "+",
"subtract" => "-",
"multiply" => "x",
"divide" => "/",
"power" => "^",
"modulo" => "%",
];
public function calculate(string $operation, float $a, float $b): float
{
return match ($operation) {
"add" => $a + $b,
"subtract" => $a - $b,
"multiply" => $a * $b,
"divide" => $b === 0.0 ? throw new DivisionByZeroError("You cannot divide by zero.") : $a / $b,
"power" => $a ** $b,
"modulo" => $b === 0.0 ? throw new DivisionByZeroError("You cannot take a remainder by zero.") : fmod($a, $b),
default => throw new InvalidArgumentException("Unknown operation."),
};
}
}
Step 2: try it from the command line
<?php
require __DIR__ . "/Calculator.php";
$calc = new Calculator();
echo $calc->calculate("add", 2, 3) . "\n";
echo $calc->calculate("divide", 10, 4) . "\n";
echo $calc->calculate("power", 2, 10) . "\n";
echo $calc->calculate("modulo", 7.5, 2) . "\n";
try {
$calc->calculate("divide", 5, 0);
} catch (DivisionByZeroError $e) {
echo $e->getMessage() . "\n";
}
try {
$calc->calculate("sqrt", 5, 0);
} catch (InvalidArgumentException $e) {
echo $e->getMessage();
}
5
2.5
1024
1.5
You cannot divide by zero.
Unknown operation.
Step 3: read and validate the form
<?php
declare(strict_types=1);
function h(string $text): string
{
return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
}
/** @return array{values: array, errors: array} */
function readCalculatorInput(array $post, array $operations): array
{
$errors = [];
$a = trim((string) ($post["a"] ?? ""));
$b = trim((string) ($post["b"] ?? ""));
$operation = (string) ($post["operation"] ?? "");
if (filter_var($a, FILTER_VALIDATE_FLOAT) === false) {
$errors["a"] = "Enter a number for the first value.";
}
if (filter_var($b, FILTER_VALIDATE_FLOAT) === false) {
$errors["b"] = "Enter a number for the second value.";
}
if (!array_key_exists($operation, $operations)) {
$errors["operation"] = "Choose an operation.";
}
return ["values" => ["a" => $a, "b" => $b, "operation" => $operation], "errors" => $errors];
}
Let us check the validation with different input:
<?php
require __DIR__ . "/Calculator.php";
require __DIR__ . "/functions.php";
$good = readCalculatorInput(["a" => "6", "b" => "3", "operation" => "divide"], Calculator::OPERATIONS);
$bad = readCalculatorInput(["a" => "six", "b" => "", "operation" => "hack"], Calculator::OPERATIONS);
echo count($good["errors"]) . " errors for good input\n";
echo implode(" | ", $bad["errors"]);
0 errors for good input
Enter a number for the first value. | Enter a number for the second value. | Choose an operation.
Step 4: the page
<?php
declare(strict_types=1);
require __DIR__ . "/Calculator.php";
require __DIR__ . "/functions.php";
$calculator = new Calculator();
$values = ["a" => "", "b" => "", "operation" => "add"];
$errors = [];
$result = null;
$message = null;
if ($_SERVER["REQUEST_METHOD"] === "POST") {
["values" => $values, "errors" => $errors] = readCalculatorInput($_POST, Calculator::OPERATIONS);
if ($errors === []) {
try {
$result = $calculator->calculate($values["operation"], (float) $values["a"], (float) $values["b"]);
} catch (DivisionByZeroError $e) {
$message = $e->getMessage();
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calculator</title>
</head>
<body>
<h1>Calculator</h1>
<form method="post" novalidate>
<p>
<label for="a">First number</label><br>
<input id="a" name="a" inputmode="decimal" value="<?= h($values["a"]) ?>">
<?php if (isset($errors["a"])): ?><br><span role="alert"><?= h($errors["a"]) ?></span><?php endif; ?>
</p>
<p>
<label for="operation">Operation</label><br>
<select id="operation" name="operation">
<?php foreach (Calculator::OPERATIONS as $key => $symbol): ?>
<option value="<?= h($key) ?>" <?= $values["operation"] === $key ? "selected" : "" ?>><?= h($key . " (" . $symbol . ")") ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($errors["operation"])): ?><br><span role="alert"><?= h($errors["operation"]) ?></span><?php endif; ?>
</p>
<p>
<label for="b">Second number</label><br>
<input id="b" name="b" inputmode="decimal" value="<?= h($values["b"]) ?>">
<?php if (isset($errors["b"])): ?><br><span role="alert"><?= h($errors["b"]) ?></span><?php endif; ?>
</p>
<button type="submit">Calculate</button>
</form>
<?php if ($result !== null): ?>
<p role="status">Result: <strong><?= h((string) round($result, 10)) ?></strong></p>
<?php endif; ?>
<?php if ($message !== null): ?>
<p role="alert"><?= h($message) ?></p>
<?php endif; ?>
</body>
</html>
Save the four files in one folder and run php -S localhost:8000. Open http://localhost:8000/index.php.
What you practised
| Skill | Where |
|---|---|
| Classes and constants | Calculator, OPERATIONS |
match and throw as an expression | calculate() |
| Exceptions | DivisionByZeroError, InvalidArgumentException |
| Validation | readCalculatorInput() |
| Escaping | h() on every printed value |
| Forms that keep their values | value="<?= h(...) ?>" |
Challenges
- Add a history: keep the last five calculations in
$_SESSIONand show them below the form. - Add square root (one number). How does the form change when an operation needs only one value?
- Support chaining: use the previous result as the first number.
- Round the display to a number of decimals the user chooses (validate it as an integer from 0 to 10).
Exercise: spot the floating-point surprise
What does $calc->calculate("subtract", 0.3, 0.1) return, and why does the page use round($result, 10) when printing?
Show the answer
It returns 0.19999999999999998, because 0.3 and 0.1 cannot be stored exactly in binary (see Floating-Point Pitfalls). Rounding to 10 decimals for display shows 0.2, while the stored value stays as accurate as a float can be.