Building a Command-Line Calculator in PHP
Design and build a working command-line calculator that accepts both single-shot arguments and interactive REPL input, validates every input, reports errors through custom exceptions, and is covered by a small PHPUnit test suite.
Every PHP developer eventually needs to write a small standalone script that runs outside a web server — a maintenance job, a data-processing tool, or, as here, a calculator you can call from the terminal. This project pulls together variables and functions, control-flow loops, input validation, and custom exceptions — all covered individually in earlier lessons — into one coherent command-line application. Along the way you'll see how a script decides whether it was invoked with arguments (php calc.php add 2 3) or should instead drop into an interactive read-eval-print loop, and how to structure the arithmetic itself so it's easy to unit test with PHPUnit, the testing tool introduced in the PHPUnit Basics and Writing Your First Test lessons.
Project Overview and Requirements
The goal is a single PHP file, calc.php, that can be run two ways. In non-interactive mode, the user supplies an operation and two numbers directly on the command line — php calc.php multiply 6 7 — and the script prints the result and exits immediately, which makes it usable from shell scripts and cron jobs. In interactive mode, running php calc.php with no arguments drops the user into a loop that repeatedly prompts for an expression like 12 / 4, prints the answer, and keeps going until the user types exit or quit. Both modes must share the exact same arithmetic logic; the only difference should be how input arrives and how output is formatted.
Five operations are required: addition, subtraction, multiplication, division, and exponentiation (power). Division must never allow a silent divide-by-zero — PHP itself would either emit a warning and return INF (float division) or throw a DivisionByZeroError for the intdiv() function, but neither of those gives a calculator-appropriate error message, so the project defines its own exception type instead. Similarly, any non-numeric input — a stray letter, an empty string, a malformed expression like 5 + — needs to be caught before it ever reaches the arithmetic and reported back to the user in plain language rather than as a raw PHP TypeError.
Framing the requirements this way up front — two entry modes, five operations, two categories of bad input, one shared core — is what turns a two-line script into something worth structuring properly with classes, exceptions, and tests. It also mirrors how real command-line tools are specified: a fixed set of operations, a well-defined error surface, and a scriptable non-interactive mode alongside a human-friendly interactive one.
• Two invocation modes: single-shot arguments and an interactive REPL loop. • Five operations: add, subtract, multiply, divide, power. • Division by zero must raise a custom, descriptive exception rather than silently returning INF. • Non-numeric or malformed input must be rejected with a clear error, not a fatal PHP TypeError. • The arithmetic core must be shared by both modes and be independently unit-testable.
Architecture: Classes, Exceptions, and File Layout
The project is split into three pieces that build directly on the OOP fundamentals and Custom Exceptions lessons. First, a Calculator class holds one public method per operation (add, subtract, multiply, divide, power), each taking two float parameters and returning a float. Keeping the arithmetic in plain, small methods with no input/output code inside them is what makes the class trivial to unit test in isolation — PHPUnit can call $calc->divide(10, 0) directly and assert that it throws, without ever touching STDIN or the command line.
Second, two custom exception classes extend PHP's built-in \Exception: DivisionByZeroException for the divide-by-zero case, and InvalidExpressionException for anything that fails to parse as a valid <number> <operator> <number> expression. Extending \Exception rather than inventing an unrelated error-reporting mechanism means the calculator's own try/catch blocks, and any future caller, can rely on standard exception behavior — a message, a stack trace, and the ability to catch either exception specifically or fall back to catching \Exception generally.
Third, a thin run.php-style entry point (in our case the bottom of calc.php, guarded so it doesn't execute during tests) is responsible only for I/O: reading $argv, or reading lines from STDIN in a loop, parsing them into an operator and two operands, calling the appropriate Calculator method, and printing either the result or a caught exception's message. This separation — a pure logic class, dedicated exception types, and a dumb I/O shell around them — is the same layering used throughout the course's OOP and Separation of Concerns material, just applied to a script instead of a web request.
In a real project these three pieces would live in separate files (src/Calculator.php, src/Exceptions/DivisionByZeroException.php, bin/calc.php) and be wired together with Composer's autoloader, exactly as covered in the Composer and Autoloading lesson. For this walkthrough everything is shown in one file for readability, but the class boundaries are drawn exactly as they would be if split apart.
• Calculator class: pure arithmetic methods, no I/O, fully unit-testable in isolation. • DivisionByZeroException and InvalidExpressionException both extend PHP's built-in \Exception. • The CLI entry point only parses input and formats output — it delegates all math to Calculator. • In a full project, autoload these classes with Composer's PSR-4 autoloader instead of manual includes.
Core Implementation: Parsing and Arithmetic
Argument parsing has to handle two shapes of input. When $argv (PHP's built-in array of command-line arguments, with $argv[0] always being the script name) has three or more elements, the script treats $argv[1] as the operation name and $argv[2]/$argv[3] as the operands. When $argv has only the script name, it switches to interactive mode and reads lines from STDIN with fgets(STDIN) inside a while (true) loop — the loop construct covered in the Loops lesson — breaking out only when the trimmed input equals exit or quit.
Validating a supposed number uses is_numeric() rather than a bare cast to float. Casting (float) "abc" silently produces 0.0 in PHP, which would let garbage input through as a valid zero — exactly the kind of silent failure the Type Juggling and Type Coercion lesson warns about. is_numeric() instead returns a real true/false answer that the parser can act on before ever calling into Calculator, so an invalid token is rejected at the parsing boundary rather than turning into a wrong answer deep inside the arithmetic.
Inside Calculator::divide(), the check is a simple guard clause: if the divisor is 0.0 (or, to be safe with floating point, within a tiny epsilon of zero), throw new DivisionByZeroException("Cannot divide {$a} by zero") immediately rather than letting PHP's own division operator run. This mirrors the fail-fast principle from the Defensive Programming lesson: detect the invalid state as early as possible and stop, rather than letting bad data propagate further into the program where it becomes harder to trace back to its source.
• $argv holds command-line arguments; 3+ elements means single-shot mode, 1 element means interactive mode. • fgets(STDIN) inside a while (true) loop reads interactive input line by line. • is_numeric() — not a float cast — is the correct way to validate that a token is a real number. • Calculator::divide() guards against a zero divisor before performing the division, not after.
Validation and Error Handling
Every user-facing error path funnels through a try/catch block in the entry point, never through a raw PHP fatal error. The catch block first catches the most specific exception types — DivisionByZeroException and InvalidExpressionException — each with its own message formatting, and falls back to a generic catch (\Throwable $e) as a safety net for anything unanticipated (a corrupt PHP installation, an out-of-memory condition, and so on). This ordering — specific exceptions first, general last — is required by PHP itself; catching a parent class before a child class it comes before would make the child's catch block unreachable, and PHP will refuse to compile a catch block ordered that way for related exception classes.
Malformed expressions deserve a genuinely descriptive message rather than a generic "invalid input". If the user types 5 +, the parser should say something like "Expected a second number after '+' but found nothing" — because a calculator that only ever prints "Error" forces the user to guess what they did wrong, while one that names the exact problem lets them fix it on the next line without re-reading documentation. This attention to message quality is a habit worth carrying into every exception class you write, not just this project's.
It's also worth deciding, and documenting, what happens to the interactive loop after an error: does one bad line crash the whole session, or does the loop catch the exception, print the message, and prompt again? For a REPL-style tool the right answer is almost always the latter — the loop keeps running after a caught exception, so a single typo doesn't cost the user their whole session's state (which, in a future extension, could include variables or calculation history).
• Catch specific exception types (DivisionByZeroException, InvalidExpressionException) before a generic \Throwable fallback. • PHP will not compile a catch block that puts a parent exception type before a child type in the same try. • Error messages should name the specific problem, not just say 'invalid input'. • In interactive mode, a caught exception should print and re-prompt, not terminate the whole loop.
Testing the Calculator with PHPUnit
Because Calculator's methods take plain numbers and return plain numbers with no I/O inside them, they are ideal candidates for the kind of fast, isolated unit tests introduced in the PHPUnit Basics lesson. A CalculatorTest class extends PHPUnit\Framework\TestCase and asserts expected results for each operation, using assertEqualsWithDelta() rather than assertEquals() for floating-point results — comparing floats for exact equality is unreliable because of how binary floating-point representation works, a point covered in the Floating-Point Numbers lesson.
The division-by-zero case is tested with $this->expectException(DivisionByZeroException::class) before calling $calc->divide(5, 0) — the pattern taught in the Testing Exceptions lesson for verifying that a specific exception type, not just any error, is thrown under a specific condition. A good test suite for this project also includes at least one test per operation with negative numbers and one with zero as a valid (non-divisor) operand, since those are the boundary cases most likely to reveal an off-by-one or sign error in a hastily written arithmetic method.
Running vendor/bin/phpunit tests/CalculatorTest.php should complete in a few milliseconds and require no database, no filesystem access, and no network — a hallmark of a well-isolated unit test, and a strong argument for keeping the arithmetic logic separate from the CLI I/O layer in the first place.
• Test Calculator in isolation — no STDIN, no argv, no I/O needed in the test suite. • Use assertEqualsWithDelta() for floating-point comparisons, never exact assertEquals() on floats. • expectException(DivisionByZeroException::class) verifies the specific exception type is thrown. • Cover negative numbers and zero-as-a-valid-operand as boundary cases.
Extending the Project
Once the core is solid, natural extensions include supporting parentheses and full expression precedence (which requires a small recursive-descent parser rather than the simple two-operand split used here), adding a memory feature (M+, MR, MC style commands) that stores a running value across REPL iterations, or reading a batch of expressions from a file passed as a command-line argument and writing results to an output file — a good exercise in combining this project with the File Handling lesson's techniques for reading and writing files line by line.
Another worthwhile extension is swapping the plain-text output for a --json flag that emits {"result": 42} instead of 42, which turns the calculator into something other small scripts or a shell pipeline could consume programmatically — the same JSON encoding technique (json_encode()) used throughout the course's API lessons.
Code Examples
The match expression (from the Match Expressions lesson) gives one clean dispatch point that both argument-based and expression-based input can call into, while every arithmetic method stays free of any parsing or I/O concerns so it can be tested and reused independently.
throw new DivisionByZeroException("Cannot divide {$a} by zero");
$argc/$argv drive the mode decision, a regular expression parses free-form expressions in interactive mode, and a single try/catch pattern — catching the two specific exception types together with the union catch syntax — handles errors identically in both modes without crashing the loop.
while (true) {... $line = fgets(STDIN);... } Each test isolates one behavior — a normal case, a boundary case (zero operand), and an error case per exception type — following the Arrange-Act-Assert structure from the Writing Your First Test lesson, and expectExceptionMessage() pins down not just that an exception is thrown but that its message is correct.
if ((float) $input === 0.0) { /* treat as invalid */ } if (!is_numeric($input)) { throw new InvalidExpressionException("'{$input}' is not a number"); }
Casting a non-numeric string to float silently yields 0.0 in PHP, which is indistinguishable from the user legitimately typing zero. Always validate with is_numeric() before casting, so garbage input is rejected rather than silently becoming a wrong answer.
return $a / $b; // no zero check at all if (abs($b) < 1e-12) { throw new DivisionByZeroException(...); } return $a / $b;
Without an explicit guard, dividing by zero on floats produces PHP's INF/NAN or, for intdiv(), an uncatchable-feeling built-in DivisionByZeroError with a generic message — neither gives the calculator's users a clear, application-specific error they can act on.
catch (\Exception $e) {... } catch (DivisionByZeroException $e) {... } catch (DivisionByZeroException $e) {... } catch (\Exception $e) {... }
PHP requires catch blocks for related exception types to be ordered from most specific to least specific; putting the general \Exception catch first makes the more specific DivisionByZeroException catch unreachable, and PHP raises a fatal compile error for this exact ordering mistake.
Why does the Calculator class avoid any direct calls to echo, fgets, or STDIN inside its arithmetic methods? A. Those functions don't exist in PHP 8 B. Keeping I/O out of the class keeps it pure and independently unit-testable with PHPUnit C. PHPUnit cannot run tests on classes that use echo anywhere in the file D. It has no real benefit; it's purely a style preference
B. Keeping I/O out of the class keeps it pure and independently unit-testable with PHPUnit — Separating pure logic from I/O means Calculator's methods can be tested by calling them directly and asserting on their return values, with no need to simulate terminal input or capture printed output.
What does is_numeric('12.5') return, and why is it preferred over (float) $input for validation? A. false, because floats need a leading zero B. true; it correctly validates before casting, whereas casting a bad string silently produces 0.0 C. It throws a TypeError in PHP 8 D. true, but it also modifies $input in place
B. true; it correctly validates before casting, whereas casting a bad string silently produces 0.0 — is_numeric() returns a genuine boolean answer about whether a string represents a valid number, letting the parser reject bad input before it is ever cast — a direct float cast on invalid input silently returns 0.0 instead of failing.
In the interactive REPL loop, what should happen after a DivisionByZeroException is caught? A. The entire script should exit immediately with a fatal error B. The exception message should print and the loop should continue prompting for new input C. PHP automatically restarts the script D. The exception should be silently ignored with no message
B. The exception message should print and the loop should continue prompting for new input — A REPL-style tool should be resilient to individual bad lines: catching the exception, printing a clear message, and looping back to the prompt keeps the session alive instead of punishing one typo with a crash.
Why does the test suite use assertEqualsWithDelta() instead of assertEquals() for arithmetic results? A. assertEquals() does not exist in PHPUnit B. assertEqualsWithDelta() runs faster C. Exact floating-point equality is unreliable due to binary floating-point representation, so a small delta/tolerance is needed D. assertEquals() only works on integers
C. Exact floating-point equality is unreliable due to binary floating-point representation, so a small delta/tolerance is needed — Floating-point arithmetic can produce tiny representation errors (e.g. 0.1 + 0.2 not being bit-for-bit exactly 0.3), so comparing floats for exact equality is fragile; assertEqualsWithDelta() allows a small acceptable tolerance.
<?php
declare(strict_types=1);
class DivisionByZeroException extends \Exception {}
class InvalidExpressionException extends \Exception {}
class Calculator
{
public function add(float $a, float $b): float
{
return $a + $b;
}
public function subtract(float $a, float $b): float
{
return $a - $b;
}
public function multiply(float $a, float $b): float
{
return $a * $b;
}
public function divide(float $a, float $b): float
{
if (abs($b) < 1e-12) {
throw new DivisionByZeroException("Cannot divide {$a} by zero");
}
return $a / $b;
}
public function power(float $base, float $exponent): float
{
return $base ** $exponent;
}
/**
* Dispatch by operator symbol/name so both CLI modes share one entry point.
*/
public function calculate(string $operator, float $a, float $b): float
{
return match ($operator) {
'add', '+' => $this->add($a, $b),
'subtract', '-' => $this->subtract($a, $b),
'multiply', '*' => $this->multiply($a, $b),
'divide', '/' => $this->divide($a, $b),
'power', '^'
…<?php
declare(strict_types=1);
require __DIR__ . '/Calculator.php';
function parseExpression(string $line): array
{
$line = trim($line);
if (!preg_match('/^(-?\d+(\.\d+)?)\s*([+\-*\/^])\s*(-?\d+(\.\d+)?)$/', $line, $m)) {
throw new InvalidExpressionException("Could not parse expression: '{$line}'");
}
return [(float) $m[1], $m[3], (float) $m[4]];
}
$calc = new Calculator();
if ($argc >= 4) {
// Single-shot mode: php calc.php add 2 3
[$op, $rawA, $rawB] = [$argv[1], $argv[2], $argv[3]];
if (!is_numeric($rawA) || !is_numeric($rawB)) {
fwrite(STDERR, "Error: operands must be numeric.\n");
exit(1);
}
try {
$result = $calc->calculate($op, (float) $rawA, (float) $rawB);
echo $result . PHP_EOL;
} catch (DivisionByZeroException | InvalidExpressionException $e) {
fwrite(STDERR, "Error: " . $e->getMessage() . PHP_EOL);
exit(1);
}
exit(0);
}
// Interactive REPL mode: php calc.php
echo "PHP CLI Calculator — type an expression like '12 / 4', or 'exit' to quit.\n";
while (true) {
echo "> ";
$line = fgets(STDIN);
if ($line === false) {
break;
}
$line = trim($l
…<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../src/Calculator.php';
final class CalculatorTest extends TestCase
{
private Calculator $calc;
protected function setUp(): void
{
$this->calc = new Calculator();
}
public function testAddsTwoPositiveNumbers(): void
{
$this->assertEqualsWithDelta(8.0, $this->calc->add(5, 3), 0.0001);
}
public function testSubtractHandlesNegativeResult(): void
{
$this->assertEqualsWithDelta(-2.0, $this->calc->subtract(3, 5), 0.0001);
}
public function testMultiplyByZeroIsZero(): void
{
$this->assertEqualsWithDelta(0.0, $this->calc->multiply(42, 0), 0.0001);
}
public function testDivideThrowsOnZeroDivisor(): void
{
$this->expectException(DivisionByZeroException::class);
$this->expectExceptionMessage("Cannot divide 10 by zero");
$this->calc->divide(10, 0);
}
public function testPowerOfZeroExponentIsOne(): void
{
$this->assertEqualsWithDelta(1.0, $this->calc->power(9, 0), 0.0001);
}
public function testUnknownOperatorThrowsInvalidExpression(): void
{
$t
…