Framework-agnostic property-based testing engine for PHP 8.3+: generators with integrated shrinking, a structured property runner, a regression corpus, lifecycle events, and stateful/model-based testing — with no dependency on any test framework. Generate hundreds of random inputs, find the failing one, and shrink it to a minimal counterexample you can actually read.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model. If the project uses
llm/skills, therasuvaeff-property-testing-coreskill is auto-synced to.agents/skills/oncomposer require— it is decision-oriented (which generator, which phase mechanism) and points back here for the full syntax. To mirror the skill into.claude/skills/or.cursor/skills/(a single set of files, OS-level junctions/symlinks), add askills.jsonat the project root:{"target": ".agents/skills", "aliases": [".claude/skills", ".cursor/skills"]}— or runcomposer skills:initfor the interactive wizard.
| Package | Use it when |
|---|---|
rasuvaeff/property-testing-core (this package) |
You drive the engine yourself: a custom harness, CI guard, CLI checker, or another framework adapter |
rasuvaeff/property-testing-testo |
You test with Testo — drop-in replacement for the frozen rasuvaeff/property-testing with the same #[Property] attribute |
rasuvaeff/property-testing-phpunit |
You test with PHPUnit — a PropertyTesting trait with a fluent forAll()->check() API |
rasuvaeff/property-testing-names |
Your inputs are people: forms, profiles, auth, validators, reports — Names::first()/last()/middle() draw single parts independently, full()/person() keep every part consistent with one gender; bundled en and ru datasets, shrinking to the shortest entry |
Note: this package
conflicts with the frozenrasuvaeff/property-testing(2.x) — both ship classes in theRasuvaeff\PropertyTestingnamespace, so Composer refuses to install them together. Migrating from 2.x? Swap the dev dependency for the adapter matching your framework; your imports stay as they are. MIGRATION.md is the full guide: two Composer commands and no PHP edits for Testo projects, plus the custom-harness and PHPUnit paths.
- PHP 8.3+
ext-mbstringext-random
composer require --dev rasuvaeff/property-testing-coreThe engine has no test-framework dependency: you hand it a property definition and an executor, it hands you back a structured result. It never reads environment variables, never prints, never exits, and never throws to report a property outcome.
Build a PropertyDefinition (generators keyed by parameter name plus a
PropertyConfig), execute the body through a TrialExecutor
(CallableTrialExecutor adapts a plain closure), and inspect the
PropertyResult the PropertyRunner returns:
use Rasuvaeff\PropertyTesting\Gen;
use Rasuvaeff\PropertyTesting\Runner\CallableTrialExecutor;
use Rasuvaeff\PropertyTesting\Runner\Falsified;
use Rasuvaeff\PropertyTesting\Runner\PropertyConfig;
use Rasuvaeff\PropertyTesting\Runner\PropertyDefinition;
use Rasuvaeff\PropertyTesting\Runner\PropertyRunner;
$definition = new PropertyDefinition(
id: 'demo::everyIntStaysBelowHundred',
name: 'everyIntStaysBelowHundred',
generators: ['value' => Gen::intBetween(0, 10_000)],
parameterNames: ['value'],
config: new PropertyConfig(runs: 200, seed: 42),
);
$result = (new PropertyRunner())->run($definition, new CallableTrialExecutor(
static function (int $value): void {
if ($value >= 100) {
throw new RuntimeException(sprintf('%d is not below 100', $value));
}
},
));
if ($result instanceof Falsified) {
$example = $result->counterExample();
// $example->seed, $example->originalArguments, $example->shrunkArguments, ...
fwrite(STDERR, $result->failure()->getMessage());
}The failure message renders the counterexample:
Property falsified after 0 successful run(s); seed=42
Original: value=54
Shrunk: value=100 (3 shrink step(s), 11 trial(s))
Changed: value=54 -> 100
Failure: 100 is not below 100
The Changed: line diffs the original against the shrunk counterexample —
arguments the shrinker left untouched are omitted. trial(s) counts every
candidate the shrinker ran (accepted and rejected); shrink step(s) counts
only the accepted ones. Reproduce the exact run by pinning the reported seed in
PropertyConfig.
See examples/standalone_runner.php for the
full runnable script.
TrialExecutor is the boundary between the engine and whatever executes the
property body. Each execute($arguments) call returns a TrialOutcome —
passed(), failed($throwable), or discarded():
CallableTrialExecutor— the standalone executor: a normal return passes,Assume::that()discards, any other throwable fails the trial.- Framework adapters implement their own (Testo maps a
TestResult, PHPUnit maps assertion exceptions) — the run/shrink loop never learns about framework types.
PropertyRunner::run() returns one of a closed PropertyResult hierarchy —
impossible data combinations are not representable, and every failing outcome
carries the engine's own exception type with the established message format:
| Result | Meaning | Carries |
|---|---|---|
Passed |
Every check completed, every coverage requirement held | RunStatistics |
Falsified |
A random run failed; the counterexample is shrunk | PropertyViolationException → CounterExample |
GaveUp |
Discard budget exhausted before runs checks |
GaveUpException, RunStatistics |
CoverageFailed |
Every run passed but a Classify::cover() requirement was missed |
CoverageViolationException, RunStatistics |
DeadlineExceeded |
A single run overran timeoutMs |
DeadlineExceededException |
TimeBudgetExceeded |
The random phase overran budgetMs |
TimeBudgetExceededException, RunStatistics |
GenerationFailed |
A generator could not produce a valid value | GenerationExhausted |
ExampleFailed |
An explicit example failed (examples run first, unshrunk) | ExampleViolationException |
RegressionFailed |
A recorded corpus entry still fails | RegressionViolationException |
PathFailed |
The run falsified the property but could not follow the pinned shrink path |
PathViolationException |
Configuration errors (runs < 1, a missing generator, mismatched parameter
names) remain exceptions — they are programmer errors, not verdicts about the
property.
RunStatistics exposes the raw phase counters (attempts, discards, checks,
per-label classification counts) so a reporter can print a distribution table
or a discard warning — the engine itself never formats framework output.
Serialization: every result survives native serialize() when captured stack
traces carry no argument values (zend.exception_ignore_args=1); the portable
machine format is CounterExample::toArray() / toJson().
All factories live on the Gen facade; each returns an implementation of
ArbitraryInterface whose generate(Random) yields a Shrinkable — the value
plus a lazy tree of smaller candidates, so transformed generators shrink
through their source domain.
| Factory | Produces | Shrinks |
|---|---|---|
Gen::int() |
IntArbitrary, PHP_INT_MIN..PHP_INT_MAX |
toward 0 |
Gen::intBetween($min, $max) |
IntArbitrary, [$min, $max] |
toward 0, clamped to range |
Gen::intPositive() |
IntArbitrary, 1..PHP_INT_MAX |
toward 1 |
Gen::float() |
FloatArbitrary, [0.0, 1.0) |
toward 0.0 |
Gen::floatBetween($min, $max) |
FloatArbitrary, [$min, $max] |
toward 0.0, clamped to range |
Gen::bool() |
BoolArbitrary, true / false |
true -> false |
Gen::string() |
StringArbitrary, Unicode, length 0..100 |
toward '', then by length, then each character toward a |
Gen::stringAscii() |
StringArbitrary, printable ASCII, length 0..100 |
toward '', then by length, then each character toward a |
Gen::stringOf($min, $max) |
StringArbitrary, Unicode, bounded length |
toward '', then by length, then each character toward a |
Gen::stringFrom($alphabet, $min, $max) |
CharsetStringArbitrary, characters from a fixed alphabet (multibyte OK) |
toward '', then by length, then each character toward the first alphabet character |
Gen::bytes($min, $max) |
BytesArbitrary, raw byte strings (bytes 0..255) |
toward '', then by length, then each byte toward "\x00" |
Gen::arrayOf($element, $min, $max) |
ArrayArbitrary, lists of $element, size 0..100 by default |
toward [], then by length, then each element |
Gen::nonEmptyArrayOf($element, $max) |
ArrayArbitrary, non-empty lists |
by length (never below 1), then each element |
Gen::uniqueArrayOf($element, $min, $max) |
UniqueArrayArbitrary, lists of pairwise-distinct elements |
like arrayOf, but element candidates colliding with another element are skipped |
Gen::subset($values, $min, $max) |
SubsetArbitrary, subsets of a fixed ordered set — distinct members of $values in source order; duplicates in the source are rejected |
size first (toward the empty set), then each kept element toward earlier source positions — the minimal subset is a short prefix |
Gen::dictOf($key, $value, $min, $max) |
DictionaryArbitrary, maps with distinct keys from $key (int/string) and values from $value, size 0..100 by default |
toward [], then by size, then each value (keys fixed) |
Gen::record($shape) |
RecordArbitrary, fixed-shape map ['field' => $arb, ...] |
each field via its arbitrary, key set fixed |
Gen::elements($array) |
OneOfArbitrary, one value from an array (array form of oneOf) |
toward earlier-listed distinct values |
Gen::enum(SomeEnum::class) |
OneOfArbitrary over the enum's cases |
toward earlier-declared cases (declare simpler cases first) |
Gen::constant($value) |
ConstantArbitrary, always $value |
does not shrink |
Gen::char() |
StringArbitrary, a single printable ASCII character |
toward a |
Gen::uuid() |
UuidArbitrary, RFC 4122 v4 UUID strings |
does not shrink |
Gen::datetime($min, $max) |
DateTimeArbitrary, UTC DateTimeImmutable, timestamp in [$min, $max] |
toward the Unix epoch, clamped |
Gen::floatSpecial() |
OneOfArbitrary over NAN, ±INF, -0.0 and the float representation edges |
toward earlier-listed specials |
Gen::intRange($min, $max) |
FlatMappedArbitrary, ordered pairs [lo, hi] with lo <= hi |
both bounds shrink, order always holds |
Gen::recursive($leaf, $wrap, $maxDepth) |
bounded recursive structures: $wrap lifts the previous level's arbitrary |
within the branch that generated the value |
Gen::oneOf(...$values) |
OneOfArbitrary, one of the given values |
toward earlier-listed distinct values (put simpler values first) |
Gen::nullable($inner) |
NullableArbitrary, null or an $inner value |
prefers null, then the inner tree |
Gen::map($inner, $fn) |
MappedArbitrary, $inner transformed by $fn |
through the inner tree, re-applying $fn |
Gen::flatMap($inner, $fn) |
FlatMappedArbitrary, dependent generator returned by $fn($innerValue) |
source value first (dependent value regenerated), then the dependent tree |
Gen::filter($inner, $predicate) |
FilteredArbitrary, $inner values satisfying $predicate (throws GenerationExhausted after 100 rejected draws — never yields an out-of-domain value) |
inner tree, pruning candidates that fail the predicate |
Gen::tuple(...$elements) |
TupleArbitrary, fixed-arity tuple, one value per element |
each position via its element, arity fixed |
Gen::frequency($pairs) |
FrequencyArbitrary, weighted choice over [weight, arbitrary] pairs |
within the branch that generated the value |
Gen::ipv4() |
IPv4 dotted-quad strings | each octet toward 0 |
Gen::ipv6() |
IPv6 addresses in the canonical RFC 5952 text form (lowercase, no leading zeros, longest zero run compressed to ::) |
each group toward 0, ending at :: |
Gen::email() |
local@label.tld addresses |
toward the shortest local/label and first TLD |
Gen::url() |
http(s)://host.tld[/path] URLs |
toward http://a.com |
Gen::json($maxDepth) |
a JSON-encodable value (null/bool/int/float/string/list/object) | within the generated structure |
Gen::jsonString($maxDepth) |
the json_encode text of Gen::json() |
through the value's tree |
Gen::regex($pattern) / Gen::stringMatching($pattern) |
strings matching a regex subset (compiled to combinators) | shorter/simpler matches (via the compiled trees) |
Gen::commands($initialModel, $commandGenerators, $min, $max) |
CommandSequenceArbitrary, valid command sequences for stateful testing |
drops command blocks, then simplifies each command |
Gen::swarm($choiceGenerator) |
SwarmArbitrary, swarm testing: each case may use only a non-empty subset of the wrapped choice generator's variants (oneOf, elements, frequency, commands) |
inside the subset the case came from — never widening back to the full alphabet |
Gen::forClass($class, $overrides) |
ClassArbitrary, instances built from what the constructor declares — the @param psalm type when there is one (int<0, 100>, non-empty-string, list<T>, 'a'|'b'), the native type otherwise; anything unreadable throws instead of guessing |
through the generated arguments, rebuilding the instance |
Numeric generators (int*, float*) are boundary-biased: roughly one draw in
five returns an in-range edge value (0, ±1, min, max for ints; 0.0 or
min for floats), where bugs cluster, instead of a uniform one. Shrinking is
unaffected. When the edges are exactly what a property cannot use — a body that
discards 0, a range end that violates a precondition — edgeCases: EdgeCases::None generates uniformly instead of spending one run in five on a
value the property throws away.
Sized generators guarantee their minimum: uniqueArrayOf/dictOf (distinct
elements/keys) and commands (applicable steps) may fall short of the drawn
size when the value space runs out, but never fall below $min — an unreachable
minimum throws GenerationExhausted rather than hand the property a too-small
value.
Random wraps an object-scoped MT19937 engine: two instances with the same
seed produce identical sequences regardless of other random calls in the
process — that is what makes reported seeds reproducible. Do not use generated
values for cryptography.
Uniform draws from the full alphabet make every case look like every other one:
a hundred draws from oneOf('push', 'pop', 'flush') almost surely contain all
three, so the bugs that need an operation to be absent are practically
unreachable. Gen::swarm() restricts a choice generator to a random, non-empty
subset of its variants for each generated case — Groce et al., Swarm Testing
(ISSTA 2012):
Gen::swarm(Gen::oneOf('push', 'pop', 'flush')); // one case sees, say, only 'pop' and 'flush'
Gen::swarm(Gen::commands($model, $commands)); // one sequence uses a subset of the commandsIt accepts the choice generators — Gen::oneOf(), Gen::elements(),
Gen::frequency(), Gen::commands() — and any Swarmable of your own;
anything else throws. Surviving frequency branches keep their weights, so a
branch that was twice as likely as its neighbour still is.
Shrinking stays inside the subset the case came from: a counterexample found
without flush never shrinks into one containing it, which is what keeps such a
finding reproducible at all. Two consequences worth knowing:
- the subset is drawn once per generated value, so wrap the generator whose
scope you mean.
swarm(commands(...))restricts a whole sequence;arrayOf(swarm(oneOf(...)))redraws per element, which is noise rather than swarm testing; - the counterexample reports the value, not the subset it was drawn from. Seed replay reproduces both.
A swarm over Gen::commands() with a non-zero $minLength can leave a case
with no applicable command; that throws GenerationExhausted, exactly as an
unrestricted generator starved by its model does.
When one input's domain depends on another — a list plus a valid index into it,
a size plus a payload of that size — Gen::flatMap() feeds each generated value
into a closure that returns the arbitrary for the final value. Unlike an
Assume::that() guard, no runs are discarded, and both levels shrink:
Gen::flatMap(
Gen::nonEmptyArrayOf(Gen::int()),
static fn(array $items): ArbitraryInterface => Gen::tuple(
Gen::constant($items),
Gen::intBetween(0, count($items) - 1), // always a valid index
),
);When several dependent values make nested flatMap awkward, draw them inside
the property body with Gen::draw() — valid only while the runner executes a
body (anywhere else it throws). Drawn values are recorded on a replay tape,
shrunk like extra parameters, and reported as draw#1, draw#2, ... in the
counterexample. With draws present, accepted shrink steps are capped (1000 by
default; an explicit maxShrinks wins) to guarantee termination.
Assume::that($condition) discards the current attempt when a precondition
does not hold — the attempt is neither a failure nor a successful check, and
runs still means successful checks. Retries stop at maxDiscards (default
runs * 10), failing with a structured GaveUpException. Construct valid
inputs (flatMap/draw) instead of discarding broadly.
Classify::label() / Classify::when() tally labels per run;
Classify::cover($condition, $label, $minPercent) turns the tally into a hard
requirement — a passing property whose label coverage falls short fails with
CoverageFailed. The counts come back on RunStatistics::$classifications;
printing a distribution report is the adapter's job.
The same contents are available as data, without parsing the printed line:
PropertyFinished::$distribution carries a DistributionReport — every label
as a LabelShare (count, share, and the cover() threshold it was registered
with), discardPercent(), unmetRequirements() and toArray() for telemetry.
Two denominators, named apart: label shares are over the successful checks (a
discard never dilutes them), the discard share is over the attempts. A label
that was required and never occurred is reported with a count of zero rather
than omitted, and coverageAssessed is false when the run ended before the
check loop completed, so a report never implies a coverage verdict the run
never reached. A
falsified run carries no distribution — it stops at the counterexample.
PropertyConfig carries every knob the engine understands — the runner reads
no environment:
| Field | Default | Meaning |
|---|---|---|
runs |
100 | Successful checks to complete (discards do not count) |
seed |
null |
Random-phase seed; null draws one (reported in failures) |
maxShrinks |
null |
Cap on accepted shrink steps; 0 disables shrinking |
maxDiscards |
null |
Discard budget; null resolves to runs * 10 |
timeoutMs |
null |
Wall-clock deadline per single run → DeadlineExceeded |
budgetMs |
null |
Wall-clock budget for the whole random phase → TimeBudgetExceeded |
shrink |
null |
ShrinkMode::Off reports the counterexample as generated; null resolves to Full |
shrinkBudgetMs |
null |
Wall-clock budget for the shrink descent; implies ShrinkMode::Bounded |
phases |
null |
Stages to perform (Phase::Examples/Corpus/Random/Shrink); null runs all of them |
derandomize |
false |
Derive an unset seed from the property id instead of drawing one |
edgeCases |
EdgeCases::Mixin |
None turns off the numeric boundary bias, for properties the edges only cost runs |
path |
null |
Replay a recorded shrink descent instead of searching for it; needs an explicit seed |
An unset seed is drawn at random, so a property that fails for one input in
fifty fails in CI only sometimes. The corpus fixes that — but only after the
first failure is recorded. derandomize: true covers the other side of that
moment:
new PropertyConfig(derandomize: true); // the same id always selects the same inputsThe seed becomes a pure function of the property's id, so a bug found locally
reproduces in CI without waiting for a corpus entry to exist, and a passing
property keeps a stable input distribution — which is what makes distribution
numbers comparable between commits. An explicit seed always wins over the
flag. The seed→values mapping is untouched: this changes which seed a run
picks, never what that seed produces.
A descent spends most of its work on candidates it rejects: the engine's own smallest integer property accepts nine steps after trying thirty-nine. The accepted steps are the last line of the falsification message — and the same value is on the counterexample — so a rerun can follow them instead of searching for them again:
Failure: value>50
Path: value:2/value:2/value:4/value:4
$counterExample->path; // 'value:2/value:2/value:4/value:4'
new PropertyConfig(seed: 1, path: 'value:2/value:2/value:4/value:4');A run that shrank nothing has no path, and the line is omitted rather than printed empty.
Each step names a node — a parameter, or an in-body draw under its draw#N
pseudo-name — and which candidate of that node's shrink enumeration was
accepted. The replay runs the body once per step rather than once per candidate.
It does not skip the random phase: reaching the failing run means executing the
runs before it, because a body can consume randomness through Gen::draw() and
discards depend on the body.
A path is a debugging aid, not a fixture. Its steps are indices into shrink
candidates, so editing a generator orphans it — that is what the regression
corpus is for. A path that no longer applies is reported as its own outcome
(PathFailed) naming the step that broke, never absorbed into a fresh search:
searching quietly would return a counterexample that looks exactly like a
successful replay. Configurations that would leave the path a no-op — no
explicit seed, a phase set without Random or Shrink, shrinking switched off, a
wall-clock shrink budget, a maxShrinks below the path's length, a malformed
path — are rejected at construction.
maxShrinks caps the accepted steps, but the cost of a descent is in the
candidates it tries — on large collections that is easily more expensive than
the random phase that found the failure. Two knobs bound it from the other side:
new PropertyConfig(shrink: ShrinkMode::Off); // report the counterexample as generated
new PropertyConfig(shrinkBudgetMs: 500); // descend for at most 500 ms, keep the best so farA shrink budget is the one knob in this package that costs determinism: how far the descent gets depends on how long the body takes, so the same seed can minimise differently on a fast and a slow machine. It answers "the descent hung", not "reproduce this exactly" — for the latter, pin the seed or rely on the corpus.
The stages of a run are a set, not a fixed sequence:
new PropertyConfig(phases: [Phase::Examples, Phase::Corpus]); // fast pull-request gate
new PropertyConfig(); // every phase (the default)| Rule | Behaviour |
|---|---|
| Empty phase set | InvalidArgumentException — a run with no phases has nothing to report |
Phase set holding anything but a Phase |
InvalidArgumentException — an unrecognised stage would simply not run, and the property would report green having checked nothing |
Phase set without Shrink |
Exactly ShrinkMode::Off; the stricter of the two knobs always wins |
Phase::Corpus |
Gates corpus replay only, and composes with replayRegressions as an AND; a fresh falsification is still recorded |
Phase set without Random |
Nothing is generated: honest zero statistics (attempts: 0, checks: 0) and coverage requirements dropped rather than assessed against an empty denominator. The result is Passed once the enabled earlier phases pass — a pinned example or a corpus entry that fails still reports its own failure |
PropertyDefinition adds the identity (id keys events and the corpus),
display name, generators, parameterNames, fixed examples (positional
tuples run before the random phase, never shrunk), and replayRegressions
(adapters turn it off when the property pins its own seed).
The PROPERTY_RUNS / PROPERTY_SEED / PROPERTY_VERBOSE / PROPERTY_DB
environment variables are adapter conventions: the adapters resolve them
into a PropertyConfig and a Corpus. The one helper the engine ships is
FilesystemCorpus::fromEnv(), which reads PROPERTY_DB when you call it.
Pass a Corpus to PropertyRunner::run() and every falsification is recorded;
recorded failures replay before the random phase — one that still fails is
reported immediately (RegressionFailed for a values entry), one that no
longer fails is pruned. No corpus argument means no replay and no filesystem
access at all.
FilesystemCorpus is the built-in implementation: one small JSON file per
property (<sha1(id)>.json, at most 8 values entries and 2 seed entries,
oldest evicted; atomic, lock-serialised writes). The format is byte-compatible
with the corpus written by rasuvaeff/property-testing 2.8 — existing corpora
keep working after the migration.
Entry (CorpusEntry) |
When | Replay |
|---|---|---|
| Values | Every minimised argument is representable as data (null/scalars/arrays/enum cases/byte strings) | One run with the exact recorded input |
| Seed | Objects, closures, or in-body Gen::draw() values in the counterexample |
The whole random phase, re-run with that seed; fenced off by the sequence epoch |
A corpus is the only memory a property has between runs, and most falsifications happen in CI — on a machine that is destroyed when the job ends. Carrying it across runs takes three steps, each of which exists because of a specific silent failure (a combined cache action does not save on a red job, which is exactly when the corpus was written); the recipe and the traps are in The corpus as a CI artifact.
RedisCorpus is the shared alternative: the same document, in Redis instead of
a directory, so a failure found on a laptop replays in CI and a failure found in
CI replays on the next laptop. It takes a small client seam — CorpusClient,
with PhpRedisCorpusClient for ext-redis and PredisCorpusClient for predis
— and writes optimistically (read, compare-and-set, retry) rather than under a
lock, giving up quietly after a few attempts: a corpus is memory, not a ledger,
and failing a passing test run to record a counterexample would be the wrong
trade.
$corpus = new RedisCorpus(new PhpRedisCorpusClient($redis));A shared corpus is a shared value space — a values entry is generator output, readable by anyone who can read that Redis.
A values entry stores the failing input verbatim, so a corpus directory is as sensitive as the data your generators produce. That is normally uninteresting — random ints and strings — but a generator seeded from a production fixture, or one that composes realistic personal or credential-shaped data, writes exactly that to disk in plain JSON. Keep the directory out of world-readable locations and out of published build artifacts, and prefer synthesising such values inside the property body over generating them, so the counterexample records the seed rather than the data.
The property id keys both the events a listener aggregates and the corpus entry that replays yesterday's counterexample, so it has to name the same property tomorrow. An adapter that derives it from a backtrace gets that for a test method and loses it for a closure, because PHP has never had a stable name for one:
PHP 8.3 Suite::{closure}
PHP 8.4+ Suite::{closure:/app/tests/StackTest.php:19}
On 8.3 every closure of a class collapses onto one id, so two properties in one file overwrite each other's recorded counterexample; from 8.4 the id carries a line number, so inserting a line above the property orphans the entry it recorded yesterday. Nothing throws either way — the corpus simply stops replaying the failure it exists to replay.
PropertyId::unstableWarning($id) returns the sentence to show for such an id,
or null when there is nothing to say. It is a diagnosis, not a fix: the engine
returns the text and the adapter prints it, because the engine never writes
anywhere itself. Adapters that let you name a property explicitly (the PHPUnit
adapter's forAll($generators, $id)) take the id you pass verbatim, which is
the fix.
Pass PropertyListener implementations to PropertyRunner::run() and observe
the whole lifecycle — this is how a console reporter, telemetry exporter, or
IDE integration attaches without any engine change:
| Event | Fired |
|---|---|
PropertyStarted / PropertyFinished |
Around the whole property (id, seed, runs / final failure or null) |
ExampleStarted / ExampleFinished |
Around each explicit example |
RunStarted / RunPassed / RunDiscarded / RunFailed |
Around each random run (arguments, draws, labels, elapsed time) |
ShrinkTried / ShrinkAccepted |
Per shrink candidate / per accepted step |
CorpusReplayed / CorpusPruned / CorpusStored |
Corpus activity |
Events carry engine data only — never framework types. A listener exception
aborts the run (an observer's failure is an infrastructure failure, not
something to hide), and a listener can never change a property's outcome. See
examples/custom_listeners.php for a console
reporter and a telemetry collector built purely on events.
The runner measures deadlines and budgets through the Clock abstraction —
MonotonicClock (the default, hrtime-based) in production, a fake clock in
tests, injected via the PropertyRunner constructor. That is what makes
timeoutMs/budgetMs behaviour exactly testable.
Any value space is reachable by implementing ArbitraryInterface directly:
generate(Random) returns a Shrinkable — the drawn value plus a lazy tree of
smaller candidates, most aggressive first, each carrying its own subtree. Draw
randomness only through the injected Random (int(), float(), bytes())
so seeded runs stay reproducible. Shrinkable::leaf($value) builds a terminal
node; Shrinkable::of($value, $closure) attaches lazily computed candidates;
Shrinkable::map($fn) transforms a whole tree. Keep every branch finite and
never yield a candidate equal to its parent — that is what guarantees shrinking
terminates.
Some bugs only surface across a sequence of operations. Implement Command
(preCondition / nextState / run / postCondition plus a __toString
label), generate valid sequences with Gen::commands(), and drive them with
StateMachine::check() inside the property body — a failed postcondition
throws PostconditionViolation naming the step, and the failing
CommandSequence shrinks to the shortest sequence that still breaks:
$definition = new PropertyDefinition(
id: 'demo::stackBehavesLikeItsModel',
name: 'stackBehavesLikeItsModel',
generators: ['sequence' => Gen::commands([], [
Gen::map(Gen::intBetween(0, 99), static fn(int $v): Command => new Push($v)),
Gen::constant(new Pop()),
])],
parameterNames: ['sequence'],
config: new PropertyConfig(runs: 200),
);
$result = (new PropertyRunner())->run($definition, new CallableTrialExecutor(
static function (CommandSequence $sequence): void {
StateMachine::check($sequence, static fn(): Stack => new Stack());
},
));CounterExample exposes seed, runsBeforeFailure, originalArguments,
shrunkArguments, shrinkSteps, shrinkTrials, skips and the underlying
failure; toArray()/toJson() return a normalized machine-readable form,
and toExamplesCode() emits runnable PHP pinning the shrunk case as a
permanent example.
ValueRenderer::render($value) produces the single-line human form used inside
counterexample messages (strings quoted and escaped, arrays and objects
summarised, recursion and depth bounded). Adapters reuse it so their verbose
output reads exactly like the failure message.
Gen::sample($arb, $count, $seed) eagerly generates values;
Gen::sampleShrinks($arb, $seed) shows one value plus its first shrink
candidates — the fastest way to check a custom arbitrary shrinks as intended.
The engine performs no I/O, SQL, shell, or network operations itself; the only
filesystem access is the opt-in FilesystemCorpus, and only when you pass it
to the runner. Random values come from PHP's MT19937 engine seeded by the
reported seed — a PRNG, not a CSPRNG; never use generated values for
cryptographic purposes, and treat seeds as reproducibility handles, not
secrets.
When you do enable the corpus, remember that it persists failing inputs as plain JSON: see Regression corpus for what that means for generators that can produce sensitive-looking data.
See examples/ for runnable scripts.
| Script | Shows | Needs server? |
|---|---|---|
basic.php |
a property that holds, one that is falsified, and tree-based shrinking | No |
generators.php |
sample, boundary bias, uuid, datetime, dictOf, record, flatMap |
No |
standalone_runner.php |
driving the engine directly: PropertyDefinition, CallableTrialExecutor, structured PropertyResult |
No |
custom_listeners.php |
a console reporter and a telemetry collector as pure PropertyListeners |
No |
No PHP/Composer on the host. Run commands in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app composer:2 composer build
docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix
docker run --rm -v "$PWD":/app -w /app composer:2 composer test
docker run --rm -v "$PWD":/app -w /app composer:2 composer release-checkOr with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-checkmake test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.