Property-Based Testing in PHP, at Last
Here is a bug that example-based tests almost never catch, and that shipped to production in more PHP apps than anyone wants to admit: a function that handles the empty string wrong. Or the lone 0. Or a multibyte character. Or a list with one element where you assumed two. You didn’t write a test for those cases because you didn’t think of those cases — and the cases you don’t think of are exactly where bugs live.
Property-based testing is the discipline that fixes this, and PHP is the one major language that never got a good, maintained tool for it. Haskell has QuickCheck. Python has Hypothesis. JavaScript has fast-check. PHP’s lone option, eris, has sat unmaintained for years. So we wrote one: forall — property-based testing for PHP, with automatic shrinking, built for Pest and PHPUnit.
Properties instead of examples
An example test says “for this input, expect that output.” A property says “for all inputs, this should hold,” and the library generates hundreds of inputs trying to break it:
use Levelbrook\Forall\Gen;
use function Levelbrook\Forall\forAll;
it('round-trips through JSON', function () {
forAll(Gen::listOf(Gen::int()))->check(function (array $numbers) {
expect(json_decode(json_encode($numbers), true))->toBe($numbers);
});
});
That runs the property against 100 generated lists — empty lists, singletons, big ones, ones full of zeros and negatives. If it ever fails, you don’t get a wall of random data. You get the simplest input that still breaks it.
Shrinking is the whole point
When a property fails on, say, [483, -1, 99117, 7, -22], that input tells you almost nothing — which element matters? Shrinking automatically reduces a failing case to its minimal form. forall does this with lazily-evaluated rose trees: every generated value carries a tree of “smaller” candidates, and on failure the engine greedily walks toward the simplest input that still fails. You get handed [0] and a seed to reproduce it:
Property failed.
Shrunk to: [0]
Originally: [483, -1, 99117, 7, -22]
After: 17 shrink step(s)
Reproduce: ->withSeed(1843920183)
Integers shrink toward zero, lists shrink by dropping elements and simplifying the survivors, strings get shorter and plainer, nullable collapses to null — and because shrinking is structural, it composes through tuple, record, and nested listOf for free.
What’s in it
forall ships composable generators (int, float, bool, string, word, element, oneOf, nullable, listOf, tuple, record) with map, filter, and bind combinators; a seeded, reproducible random source; and a fluent runner — forAll(...)->withRuns(1000)->withSeed(42)->check(...). Properties can return a boolean or throw any assertion (Pest’s expect, PHPUnit asserts, native assert). It’s dependency-free, MIT-licensed, and targets PHP 8.2+. The engine is, fittingly, tested with itself — we use properties to assert that shrinking finds the documented minimal cases.
Where it pays off
Reach for properties on the code that’s easy to get almost right: serializers and parsers (decode(encode(x)) == x), money and rounding math, sanitizers and normalizers (f(f(x)) == f(x)), sort and dedup invariants, and any “the fast path should agree with the obvious slow path” oracle. One property replaces a dozen hand-picked examples and goes hunting for the boundary you forgot.
composer require --dev levelbrook/forall
It’s the kind of tool you write because you’ve been burned by the input you didn’t think of — and we’d rather the next PHP team didn’t have to be.