Modes
A mode decides what an unmatched call does — a call nothing configured accepted.
| Mode | Unmatched call answers with |
|---|---|
| Loose (default) | a type-safe default: null, 0, '', [], an empty generator … |
Strict — Understudy::strict($double) | an immediate failure naming the method, the call, and what did not accept it |
Forwarding — Understudy::forwarding($double, $real) | whatever the real instance answers, recorded like any other call |
Strictness is per double, not per test. It is a different setting from strict stubs, which is about registrations nobody used.
Understudy::strict() and Understudy::label() answer with the double they configured, so the mode can be chosen where the double is handed over:
$definitions = [
ClientInterface::class => Understudy::strict(Understudy::for(ClientInterface::class)),
];What loose will and will not invent
A loose double never invents a value by running another class's constructor, and never hands back an unconstructed instance of a real class.
What it can hand back is another understudy: a return type that can itself be doubled becomes one, one level deep, which the same test can configure. That double is a generated stand-in, not the target with its constructor skipped.
One level, and no further. A double created this way refuses to produce another, so $a->b()->c() says so rather than inventing a third collaborator the test never asked for. Registering a factory for C is how you say you meant it — see Defaults registry.
A built-in interface is the exception: a method declared : Stringable, : Countable, : JsonSerializable, : ArrayAccess or : IteratorAggregate answers NoDefaultValue and names the way out, even though Understudy::for() doubles all five. Such a return type almost always means a concrete implementation, and standing a stub in for it would answer a question the test did not ask. An interface of your own that extends one of them does get a nested double.
Where no safe value exists, the double says so, and names the way out.
Strict fails at the call
Understudy `BookRepository` is strict and received an unexpected call to `tag()`.
The call was:
tag('beta', 1)
Nothing configured for `tag` accepted it:
tag(*string(matches: /^a/)*, *2*)
Configure it first: when(fn () => $double->tag(...))->returns(...)The marks are read from the expectation's side: each one is an argument that rejected this call. Everything configured for that method is listed, because a stub that could never have matched is often exactly the one the test meant to write. See Failure messages.
Forwarding
Covered in full on Forwarding to a real object — it is a mode, but it carries enough of its own rules to need a page.