Back to blog

Auditing the math module to full PHP parity

Pext's math module reached full PHP parity this week. That label means something specific: every public function, every constant, the new RoundingMode enum, on every PHP version from 4 forward, with every documented edge case checked. The road there ran through the gaps between JavaScript's Math, IEEE 754, C99, and twenty years of PHP compatibility decisions. This post walks through the parts worth knowing about.

Two test suites running in parallel

The math module is tested from two directions. The first suite is PHP, run through the converted PHPUnit on top of the math module: every function, every documented edge case, every quirk encoded as a PHP test, executed end-to-end. If transpiled PHP code disagrees with native PHP on any input, the test fails. The second suite is JavaScript, run directly against the module's exports without going through the transpiler. It catches bugs in the JS implementation that wouldn't surface from PHP code, like calls with values the transpiler never emits.

The two suites overlap deliberately. A bug that escapes one usually shows up in the other, and "fix in one place, regress in the other" is the most common shape of a math regression. Running both on every change keeps that closed.

PHP 8 ValueError vs PHP 7 warnings

PHP 8 turned a long list of soft failures into hard errors. Functions that used to emit a warning and return false now throw ValueError for the same input. The math module has to do whichever the target runtime asked for, and "the target runtime" is a per-project setting, not a build-time flag.

The fix is a runtime check on the configured compatibility level, made at the entry of each affected function. If the project is configured for PHP 7, the function returns false and the warning is logged; if configured for PHP 8 or higher, it throws. The cost is one comparison per call. The benefit is that one math module covers PHP 4 through 8.4 from a single build.

Operators that change shape with PHP_INT_SIZE

Pext's integer representation is fixed. PHP int values are always JavaScript BigInt, PHP float values are always Number. The split isn't about precision, it's about preserving the int/float distinction PHP exposes through is_int, is_float, and gettype. JavaScript's Number is one IEEE 754 type and can't carry that tag, so the runtime uses BigInt as the integer carrier and Number as the float carrier. Pext never mixes the two for the same PHP value.

The variability lives elsewhere, in PHP itself. PHP ships in 32-bit and 64-bit builds, and the difference (PHP_INT_SIZE 4 vs 8) changes how a long list of operators and built-in functions behave: PHP_INT_MAX shifts from 231-1 to 263-1, integer arithmetic overflows or promotes to float at different thresholds, shift operators have different widths, modulo edges differ. The math module reads PHP_INT_SIZE from the runtime context on every call that cares and picks the matching code path. A 32-bit project sees 32-bit overflow behaviour everywhere; a 64-bit project sees 64-bit. The arithmetic itself runs on BigInt either way; only the bounds and the overflow rule change.

C99 pow vs JavaScript Math.pow

PHP follows IEEE 754 and C99 for pow. JavaScript's Math.pow deviates from C99 in exactly two cases. pow(1, NaN) returns 1 in C99 and PHP, but NaN in JavaScript. pow(-1, ±Infinity) is the same story: 1 in C99 and PHP, NaN in JavaScript.

The math module patches those two inputs explicitly before delegating to Math.pow. They're the only IEEE 754 / C99 / Math.pow divergences the audit found, and they're easy to spot precisely because they're the only ones. Every other input lands on Math.pow directly.

max and min as comparable-aware operators

PHP's max and min have two overloads: a variadic list of arguments, or a single array. Inside, neither one does just a numeric comparison. They run PHP's general comparison rules, which respect objects that override comparison: DateTimeImmutable, anything implementing the comparison hook, GMP numbers, BC numbers. Pext's max and min route every comparison through the runtime's comparator instead of Math.max or a JavaScript >, so an array of DateTimeImmutable instances comes back with the latest one, not a string-coerced surprise.

The variadic-vs-array overload is detected at the entry; both shapes flow through the same comparator afterwards.

pow as an overridable operator

pow($a, $b) in PHP is the same operation as $a ** $b. The ** operator is overridable: GMP numbers, BC numbers, and any user class with the right internal hook can intercept it. So pow can't shortcut to Math.pow when the operands might be objects. It has to call into the same operator dispatcher the ** token uses, which gives every potentially-overriding object a chance to handle the call.

This matters in practice because GMP power and Math.pow diverge as soon as the exponent leaves the safe range. Routing through the operator preserves that and keeps pow consistent with infix ** everywhere it appears.

round taking legacy constants and the enum

PHP 8.4 introduced the RoundingMode enum to replace the four PHP_ROUND_HALF_* integer constants. It did not remove the constants. round($value, $precision, $mode) accepts either form, and the four legacy constants map to the matching enum cases. The math module normalises both at the entry of round and routes through one internal implementation. PHP_ROUND_HALF_UP and RoundingMode::HalfAwayFromZero share a code path, share a behaviour, and pass the same tests.

Next step: inlining the trivial functions

Several math functions came out of the audit with zero divergence: sin, cos, tan, sqrt, plus the hyperbolic and inverse-trig family. JavaScript's Math matches PHP, IEEE 754, and C99 on every input we tested. For these, calling through the runtime is pure overhead.

The next step is in the transpiler: when conversion sees a call to one of the green-list functions and can prove the operand is numeric (not an object that might override the call), it will emit Math.sin(x) directly instead of routing through the runtime. Same behaviour, fewer indirections, a measurable speedup in inner loops.

With the audit done and the inlining work scoped, math is firmly in the "stable, tested, done" column. The full list of supported functions, constants, and the RoundingMode enum lives on the features page, and the math module's pass rate alongside the rest of the open-source showcase lives on the case studies page.