Fuzz is a package by Jon Purvis that brings coverage-guided fuzz testing to Pest 5. It builds on nikic's PHP-Fuzzer, mutating strings you provide and feeding them to your code until it finds a failure or exhausts its run budget. A passing run means no failure was found in those attempts; bugs may still remain.

tests/Unit/RateLimitTest.php
use App\RateLimit;
use function Fuzz\fuzz;

$target = static function (string $input): void {
    RateLimit::perSecond($input);
};

test('rate limit spec parser never fatals', function () use ($target): void {
    fuzz($target)
        ->seed(['100/60s', '5/1s', '1000/3600s'])
        ->withDictionary(['/', 's', '0', '1'])
        ->runs(2000)
        ->maxLen(16)
        ->run('rate-limit-parser');
});

A coverage-guided fuzzer watches which routes inputs take through the code. When an input reaches a previously unexplored route, the fuzzer saves it in a corpus and uses it to create more variations. PHP-Fuzzer collects this feedback by tracking transitions between blocks of PHP code and roughly how often they run. Fuzz handles the instrumentation itself, so Xdebug and Pest's --coverage option are not needed.

Fuzz requires PHP 8.4+ and Pest 5. Install it with `composer require jonpurvis/fuzz --dev`. The walkthrough uses a RateLimit helper that converts a spec like 100/60s into requests per second but does not validate the input. In the test, a target function calling the helper is defined outside test(). Then fuzz() is configured with seed(), withDictionary(), runs(2000), maxLen(16), and a unique name in run() that Fuzz uses to separate saved inputs and crash files.

Defining the target outside test() matters: in the author's check of Fuzz v1.0.1, the wrapper recorded coverage while Closure::fromCallable() recorded none, because Fuzz runs the function in a separate PHP process where Pest's generated test class is unavailable.

In the author's run, Fuzz found the input 5/. The missing window becomes an empty string. PHP casts it to 0, and the division throws a DivisionByZeroError. Pest reports the test as failed. Failing inputs are saved under .pest/fuzz-crashes/ by default. After fixing the parser, the recommended practice is to add the input to a named dataset and assert the expected behavior.

Crashes are not the only failures worth catching. A wrong return value can pass silently, so you can add a Pest expectation inside the target function, for example checking that encoding then decoding a string returns the original. Fuzz reports TypeError and unsuppressed PHP warnings and notices. Ordinary exceptions, including Laravel validation exceptions, are ignored by default; the allow() method narrows that list, and timeout() sets a per-input limit, which requires the pcntl extension.

The author advises keeping regular tests and datasets for known cases. Fuzz shines when code accepts more inputs than you can reasonably list, such as parsers reading user-supplied text, and especially in parsers with successive validation steps where coverage guidance helps the fuzzer reach deeper code. Start with a small run budget in the normal suite and move longer searches to a scheduled CI job.