Generating from a class
jqwik has type-driven @ForAll, Rust has derive(Arbitrary). Both read the types the language already carries. PHP has no macros — but it does have promoted constructor properties and psalm annotations, and in a codebase that writes them the information a derive macro would need is already there, going to waste.
Gen::forClass(Money::class);
Gen::forClass(Money::class, ['amount' => Gen::intPositive()]);The annotations are the point
These two classes have the same native types:
final readonly class LooseMoney
{
public function __construct(public int $amount, public string $label) {}
}
final readonly class Money
{
/**
* @param int<0, 1000> $amount
* @param non-empty-string $label
* @param list<int> $tags
*/
public function __construct(
public int $amount,
public string $label,
public array $tags,
public Currency $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be greater than or equal to 0');
}
}
}And a very different value space, which is what the generator produces (examples/for_class.php):
Native types — anything an int can be:
amount=-1
amount=-1974865818361177172
amount=-9223372036854775808
Annotated — int<0, 1000>, non-empty-string, list<int>, an enum:
amount=1 currency=Eur tags=6 label-length=22
amount=117 currency=Usd tags=9 label-length=55
amount=0 currency=Usd tags=3 label-length=7Guessing int for the second class would have every fifth value rejected by its own constructor. Reading int<0, 1000> is the difference between a generator that matches the domain and a demo.
What it reads, in order
Per parameter: an override, then a #[Generate] attribute, then the @param docblock, then the native type. The docblock wins over the native type because it says more; a docblock written across several lines is read like one on a single line.
| Written | Generated |
|---|---|
int, float, string, bool | the matching generator |
int<0, 100>, int<min, 10>, int<0, max> | Gen::intBetween() with those bounds |
positive-int, negative-int, non-negative-int, non-positive-int | the matching range |
non-empty-string | Gen::stringOf(1, 100) |
non-falsy-string, truthy-string | non-empty and never "0" |
lowercase-string, non-empty-lowercase-string | strings mb_strtolower() leaves unchanged |
numeric-string | strings is_numeric() accepts, weighted towards signs, exponents, bare dots and surrounding whitespace |
list<T>, non-empty-list<T>, T[] | Gen::arrayOf() |
array<K, V>, non-empty-array<K, V> | Gen::dictOf() |
array{id: int, name?: string}, array{'quoted key': T, 3: U} | a shape; an optional key is sometimes absent and shrinks towards absent — absent is not null |
array{int, string}, list{int, string}, array{} | positional shapes and the empty array |
'draft'|'published', 1|2|3 | Gen::elements() — a domain spelled out in the type |
A|B | Gen::frequency() over both |
?T | Gen::nullable() |
| an enum | Gen::enum() |
DateTimeImmutable | Gen::datetime() |
| another class | followed recursively, to maxDepth |
Anything else — a bare array, mixed, callable, an intersection, an unsealed shape (array{a: int, ...}), a variadic constructor — is an exception naming the parameter, never a widened guess:
Cannot generate …\Unreadable: parameter $anything is typed array, which this
cannot read; pass an override or #[Generate]A parameter that really accepts anything is still refused rather than filled with arbitrary values: pass Gen::mixed() as its override when the property is about narrowing untrusted input.
A class it cannot instantiate — a value object with a private constructor and named factories, say — names the chain that reached it, because such a class is usually three levels down from the one you asked for:
Cannot generate …\Duration: it is not instantiable (reached through
…\BreakerConfig -> …\Ratio -> …\Duration); pass an overrideThat refusal is the design. A guessed generator does not fail here; it fails later, in somebody's test, as a value their code was never meant to see.
A generator on the parameter
When no type says what a parameter needs, the parameter can carry its generator itself:
use Rasuvaeff\PropertyTesting\Arbitrary\IntArbitrary;
use Rasuvaeff\PropertyTesting\Generate;
public function __construct(
#[Generate(new IntArbitrary(0, 10_000))]
public int $base,
#[Generate([Generators::class, 'email'])]
public string $owner,
) {}The argument is either an arbitrary built with new or a reference to a static factory that returns one — a method name of the declaring class, 'Class::method', [Class::class, 'method'], an invokable object. An attribute argument is a constant expression: Gen::* calls cannot appear in it, and on PHP 8.3 and 8.4 neither can a closure, so Gen::map(), Gen::email(), Gen::regex() and the rest of the closure-built generators go through a factory reference.
The attribute is exact. It wins over a docblock it disagrees with, stands in for one that cannot be read, and on a ?T parameter it is not wrapped in Gen::nullable() on top. Anything wrong with it — constructor arguments the arbitrary rejects, a method that is not static, a factory returning something other than an arbitrary — is an exception naming the parameter.
Generate belongs to test code: on a production constructor it would make this package a runtime dependency.
A validating constructor
Constructors in this family reject bad input, and by default a rejection propagates. That is information: the generator does not match the domain, and an override or a narrower annotation is the fix.
skipInvalid: true is the deliberate opposite — the value is discarded and redrawn by the same Gen::filter() every other filtered generator uses, with the same hundred attempts and the same GenerationExhaustedException at the end. Only exceptions are discarded, never Errors: a TypeError means the generator produced the wrong type, which is a bug rather than a rejected value.
Cycles and depth
A class reachable from itself is refused with the chain named, rather than followed until the stack ends; so is a chain deeper than maxDepth (3 by default). An override on the offending parameter breaks either.
Shrinking is unaffected
The instance is built by mapping over the generated arguments, so integrated shrinking works through it: the counterexample shrinks by shrinking the arguments and rebuilding the object, and with skipInvalid the candidates the constructor would reject are pruned from the descent rather than thrown from it.
The same rules for a whole property
Gen::forParameters() applies the same resolution — an override, #[Generate], the @param psalm type, then the native type, with the same supported subset and the same refusals — to the parameters of any function, method, or closure, and returns the map a property needs: array<string, ArbitraryInterface> by parameter name, in signature order.
Both adapters expose it as an opt-in auto mode, so a fully typed property needs no provider at all. Under Testo:
/**
* @param int<1, 300> $base
* @param int<1, 86400> $cap
*/
#[Property(auto: true)]
public function delayStaysWithinCap(int $base, int $cap): void { /* … */ }Under PHPUnit, where the docblock sits on the closure itself:
$this->forAll()
->auto()
->check(
/**
* @param int<1, 300> $base
* @param int<1, 86400> $cap
*/
function (int $base, int $cap): void { /* … */ },
);The provider — the <method>Generators convention, an explicit generators argument, the forAll() map — becomes the overrides and may be partial: the parameters it names are taken as given, the rest are derived. That is the escape hatch for a domain no psalm type can express — a float range, a dependent pair built with flatMap():
/** @param int<1, 40> $attempt */
#[Property(generators: 'provide', auto: true)]
public function delayIsMonotonic(float $multiplier, int $attempt): void { /* … */ }
/** @return array<string, ArbitraryInterface> */
public static function provide(): array
{
return ['multiplier' => Gen::floatBetween(1.0, 4.0)]; // the rest is derived
}Two deliberate edges. Auto is opt-in and stays opt-in: a bare int or float derives its full native domain, and only the property's author knows whether that is the one they meant. And under auto, a provider key that is not a parameter of the property is an error — merge semantics would otherwise silently replace a mistyped entry with a signature-derived generator.
Without auto, the adapters still read #[Generate]: through Gen::forParameters(derive: false), which takes only overrides and attributes and refuses a parameter with neither. A property whose parameters all carry the attribute needs no provider and no auto:
#[Property]
public function delayStaysWithinCap(
#[Generate(new IntArbitrary(1, 300))]
int $base,
#[Generate(new IntArbitrary(1, 86_400))]
int $cap,
): void { /* … */ }There is no skipInvalid here: nothing is executed at derivation time, and a property body filters untrusted input through Assume, as always.